Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/Data/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,23 +109,28 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasForeignKey(r => r.SourceChannelId)
.OnDelete(DeleteBehavior.Restrict);

// Routing engine: 1:1 read models keyed on ChannelId.
// Routing engine read models are keyed per (channel, managed node), not per channel:
// a channel between two managed nodes has one row per side, since local balance, flow
// history and fee policy are all per-node views of the same channel.
modelBuilder.Entity<ChannelRoutingState>()
.HasOne(x => x.Channel)
.WithOne()
.HasForeignKey<ChannelRoutingState>(x => x.ChannelId)
.WithMany()
.HasForeignKey(x => x.ChannelId)
.OnDelete(DeleteBehavior.Cascade);

// Only one ChannelRoutingState per channel.
modelBuilder.Entity<ChannelRoutingState>().HasIndex(x => x.ChannelId).IsUnique();
// One ChannelRoutingState per channel per managed node.
modelBuilder.Entity<ChannelRoutingState>()
.HasIndex(x => new { x.ChannelId, x.ManagedNodePubKey }).IsUnique();

modelBuilder.Entity<ChannelFeeState>()
.HasOne(x => x.Channel)
.WithOne()
.HasForeignKey<ChannelFeeState>(x => x.ChannelId)
.WithMany()
.HasForeignKey(x => x.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
// Only one ChannelFeeState per channel.
modelBuilder.Entity<ChannelFeeState>().HasIndex(x => x.ChannelId).IsUnique();

// One ChannelFeeState per channel per managed node.
modelBuilder.Entity<ChannelFeeState>()
.HasIndex(x => new { x.ChannelId, x.ManagedNodePubKey }).IsUnique();

// These default ON: existing rows must be backfilled true (the C# initializer
// only affects new in-code instances, not the DB column default / migration backfill).
Expand Down
16 changes: 13 additions & 3 deletions src/Data/Models/ChannelFeeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,25 @@
namespace NodeGuard.Data.Models;

/// <summary>
/// Per-channel fee-engine state (1:1 with <see cref="Channel"/>). Holds last-applied
/// policy and control state that must survive restarts.
/// Fee-engine state for one channel <b>as seen by one managed node</b> — keyed by
/// (<see cref="ChannelId"/>, <see cref="ManagedNodePubKey"/>). Holds last-applied policy and
/// control state that must survive restarts.
/// <para>
/// Each side of a channel sets its own outbound policy, so a channel between two managed nodes
/// carries one row per side (mirrors <see cref="ChannelRoutingState"/>).
/// </para>
/// </summary>
public class ChannelFeeState : Entity
{
/// <summary>FK to <see cref="Channel"/> (unique — one fee state per channel).</summary>
/// <summary>FK to <see cref="Channel"/> (unique together with <see cref="ManagedNodePubKey"/>).</summary>
public int ChannelId { get; set; }
public Channel Channel { get; set; } = null!;

/// <summary>
/// 66-hex pubkey of the managed node whose outbound/inbound policy this row tracks.
/// </summary>
public string ManagedNodePubKey { get; set; } = null!;

public DateTimeOffset? LastFeeUpdateAt { get; set; }
public long? LastAppliedOutboundBaseFeeMsat { get; set; }
public uint? LastAppliedOutboundPpm { get; set; }
Expand Down
16 changes: 13 additions & 3 deletions src/Data/Models/ChannelRoutingState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,31 @@ public enum PeerFlowCategory
}

/// <summary>
/// Per-channel routing-engine read model (1:1 with <see cref="Channel"/>). Written by
/// Routing-engine read model for one channel <b>as seen by one managed node</b> — keyed by
/// (<see cref="ChannelId"/>, <see cref="ManagedNodePubKey"/>). Written by
/// TargetRatioReevaluationJob; read by the fee engine and rebalancer. This is the single
/// canonical place target ratio / category / smoothed balance live — actuators must not
/// re-derive them.
/// <para>
/// A channel between two managed nodes has <b>one row per side</b>. Local balance, forwarding
/// history and fee policy are all per-node views of the same channel, so each node needs its own
/// signal: with a single shared row, whichever node did not own it was blind to its own depleted
/// channels and could never classify them as rebalance destinations.
/// </para>
/// </summary>
public class ChannelRoutingState : Entity
{
/// <summary>FK to <see cref="Channel"/> (unique — one routing state per channel).</summary>
/// <summary>FK to <see cref="Channel"/> (unique together with <see cref="ManagedNodePubKey"/>).</summary>
public int ChannelId { get; set; }
public Channel Channel { get; set; } = null!;

/// <summary>LND short-channel-id snapshot, refreshed every evaluation (alias -&gt; confirmed scid).</summary>
public ulong ChanIdLnd { get; set; }

/// <summary>66-hex pubkey of the managed node that owns routing state for this channel.</summary>
/// <summary>
/// 66-hex pubkey of the managed node this state belongs to — the side whose local balance,
/// flow history and fee policy the row describes.
/// </summary>
public string ManagedNodePubKey { get; set; } = null!;

/// <summary>Dynamic target local-balance ratio, clamped to [0.10, 0.90]. Defaults to 0.5.</summary>
Expand Down
31 changes: 11 additions & 20 deletions src/Data/Repositories/ChannelFeeStateRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,30 @@ public ChannelFeeStateRepository(IDbContextFactory<ApplicationDbContext> dbConte
_dbContextFactory = dbContextFactory;
}

public async Task<ChannelFeeState?> GetByChannelId(int channelId)
public async Task<ChannelFeeState?> GetByChannelIdAndNode(int channelId, string managedNodePubKey)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

return await context.ChannelFeeStates
.FirstOrDefaultAsync(x => x.ChannelId == channelId);
.FirstOrDefaultAsync(x => x.ChannelId == channelId && x.ManagedNodePubKey == managedNodePubKey);
}

public async Task<List<ChannelFeeState>> GetByManagedNodePubKey(string managedNodePubKey)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

// ChannelFeeState carries no node pubkey; the owning node lives on ChannelRoutingState
// (1:1 with the same Channel), so filter through it.
var channelIds = context.ChannelRoutingStates
.Where(s => s.ManagedNodePubKey == managedNodePubKey)
.Select(s => s.ChannelId);

return await context.ChannelFeeStates
.Include(x => x.Channel)
.Where(x => channelIds.Contains(x.ChannelId))
.Where(x => x.ManagedNodePubKey == managedNodePubKey)
.ToListAsync();
}

public async Task UpsertByChannelId(ChannelFeeState state)
public async Task UpsertByChannelAndNode(ChannelFeeState state)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

var existing = await context.ChannelFeeStates
.FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId);
.FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId
&& x.ManagedNodePubKey == state.ManagedNodePubKey);

if (existing == null)
{
Expand Down Expand Up @@ -90,14 +84,15 @@ public async Task<bool> DeleteByChannelId(int channelId)
await using var context = await _dbContextFactory.CreateDbContextAsync();

var existing = await context.ChannelFeeStates
.FirstOrDefaultAsync(x => x.ChannelId == channelId);
.Where(x => x.ChannelId == channelId)
.ToListAsync();

if (existing == null)
if (existing.Count == 0)
{
return false;
}

context.ChannelFeeStates.Remove(existing);
context.ChannelFeeStates.RemoveRange(existing);
await context.SaveChangesAsync();
return true;
}
Expand All @@ -106,12 +101,8 @@ public async Task<bool> DeleteByManagedNodePubKey(string managedNodePubKey)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

var channelIds = context.ChannelRoutingStates
.Where(s => s.ManagedNodePubKey == managedNodePubKey)
.Select(s => s.ChannelId);

var states = await context.ChannelFeeStates
.Where(x => channelIds.Contains(x.ChannelId))
.Where(x => x.ManagedNodePubKey == managedNodePubKey)
.ToListAsync();

if (states.Count == 0)
Expand Down
10 changes: 5 additions & 5 deletions src/Data/Repositories/ChannelRoutingStateRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ public ChannelRoutingStateRepository(IDbContextFactory<ApplicationDbContext> dbC
_dbContextFactory = dbContextFactory;
}

public async Task<ChannelRoutingState?> GetByChannelId(int channelId)
public async Task<ChannelRoutingState?> GetByChannelIdAndNode(int channelId, string managedNodePubKey)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

return await context.ChannelRoutingStates
.FirstOrDefaultAsync(x => x.ChannelId == channelId);
.FirstOrDefaultAsync(x => x.ChannelId == channelId && x.ManagedNodePubKey == managedNodePubKey);
}

public async Task<List<ChannelRoutingState>> GetByManagedNodePubKey(string managedNodePubKey)
Expand All @@ -49,12 +49,13 @@ public async Task<List<ChannelRoutingState>> GetByManagedNodePubKey(string manag
.ToListAsync();
}

public async Task UpsertByChannelId(ChannelRoutingState state)
public async Task UpsertByChannelAndNode(ChannelRoutingState state)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

var existing = await context.ChannelRoutingStates
.FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId);
.FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId
&& x.ManagedNodePubKey == state.ManagedNodePubKey);

if (existing == null)
{
Expand All @@ -67,7 +68,6 @@ public async Task UpsertByChannelId(ChannelRoutingState state)
else
{
existing.ChanIdLnd = state.ChanIdLnd;
existing.ManagedNodePubKey = state.ManagedNodePubKey;
existing.TargetLocalRatio = state.TargetLocalRatio;
existing.PeerFlowCategory = state.PeerFlowCategory;
existing.PendingCategory = state.PendingCategory;
Expand Down
13 changes: 7 additions & 6 deletions src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,25 @@ namespace NodeGuard.Data.Repositories.Interfaces;

public interface IChannelFeeStateRepository
{
Task<ChannelFeeState?> GetByChannelId(int channelId);
Task<ChannelFeeState?> GetByChannelIdAndNode(int channelId, string managedNodePubKey);

/// <summary>
/// All fee-state rows for channels owned by the given managed node.
/// All fee-state rows belonging to the given managed node.
/// Used by the fee engine to batch per-node state.
/// </summary>
Task<List<ChannelFeeState>> GetByManagedNodePubKey(string managedNodePubKey);

Task UpsertByChannelId(ChannelFeeState state);
Task UpsertByChannelAndNode(ChannelFeeState state);

/// <summary>
/// Deletes the fee-state row for a single channel, if present.
/// Deletes the fee-state rows for a single channel — every managed side of it, since
/// <see cref="Channel.IsDynamicFeeEnabled"/> is a channel-level opt-out.
/// </summary>
/// <returns><c>true</c> if a row was deleted; <c>false</c> if none existed.</returns>
/// <returns><c>true</c> if any row was deleted; <c>false</c> if none existed.</returns>
Task<bool> DeleteByChannelId(int channelId);

/// <summary>
/// Deletes the fee-state rows for every channel owned by the given managed node.
/// Deletes the fee-state rows belonging to the given managed node.
/// </summary>
/// <returns><c>true</c> if any rows were deleted; <c>false</c> if none existed.</returns>
Task<bool> DeleteByManagedNodePubKey(string managedNodePubKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,9 @@ namespace NodeGuard.Data.Repositories.Interfaces;

public interface IChannelRoutingStateRepository
{
Task<ChannelRoutingState?> GetByChannelId(int channelId);
Task<ChannelRoutingState?> GetByChannelIdAndNode(int channelId, string managedNodePubKey);

Task<List<ChannelRoutingState>> GetByManagedNodePubKey(string managedNodePubKey);

/// <summary>
/// Insert-or-update keyed on <see cref="ChannelRoutingState.ChannelId"/>. Load-then-update
/// is sufficient because TargetRatioReevaluationJob is the sole writer under
/// [DisallowConcurrentExecution].
/// </summary>
Task UpsertByChannelId(ChannelRoutingState state);
Task UpsertByChannelAndNode(ChannelRoutingState state);
}
13 changes: 8 additions & 5 deletions src/Jobs/ChannelFeeOptimizerJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,10 @@ private async Task OptimizeNode(
private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerTunables tunables, DateTimeOffset now)
{
var routingState = candidate.RoutingState;
var feeState = candidate.FeeState ?? new ChannelFeeState { ChannelId = candidate.DbChannel.Id };
var feeState = candidate.FeeState ?? new ChannelFeeState {
ChannelId = candidate.DbChannel.Id,
ManagedNodePubKey = node.PubKey,
};

var decision = FeeOptimizerService.ComputeNextPolicy(
routingState.EmaLocalRatio,
Expand All @@ -211,7 +214,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT
{
_logger.LogInformation("Channel {ChanId} on {NodeName}: {Action} ({Reason})",
candidate.LndChannel.ChanId, node.Name, decision.Action, decision.Reason);
await _feeStateRepository.UpsertByChannelId(feeState);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
return;
}

Expand All @@ -222,7 +225,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT
{
_logger.LogWarning("Skipping channel {ChanId} on {NodeName}: current fee policy unavailable",
candidate.LndChannel.ChanId, node.Name);
await _feeStateRepository.UpsertByChannelId(feeState);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
return;
}

Expand All @@ -241,7 +244,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT
feeState.LastAppliedInboundPpm = decision.InboundPpm;
feeState.LastFeeUpdateAt = now;

await _feeStateRepository.UpsertByChannelId(feeState);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
return;
}

Expand Down Expand Up @@ -269,7 +272,7 @@ await _lightningService.SetChannelFeePolicy(
_logger.LogInformation("{NodeName} chan {ChanId}: set outbound {Outbound}ppm inbound {Inbound}ppm ({Reason})",
node.Name, candidate.LndChannel.ChanId, decision.OutboundPpm, decision.InboundPpm, decision.Reason);

await _feeStateRepository.UpsertByChannelId(feeState);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
}
catch (Exception ex)
{
Expand Down
17 changes: 7 additions & 10 deletions src/Jobs/TargetRatioReevaluationJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public async Task Execute(IJobExecutionContext context)
{
try
{
await ReevaluateNode(managedNode, managedNodes, openChannelsByChanId);
await ReevaluateNode(managedNode, openChannelsByChanId);
}
catch (Exception ex)
{
Expand All @@ -103,7 +103,6 @@ public async Task Execute(IJobExecutionContext context)

private async Task ReevaluateNode(
Node managedNode,
IReadOnlyCollection<Node> managedNodes,
IReadOnlyDictionary<ulong, Channel> openChannelsByChanId)
{
var chainTip = await _lightningService.GetBlockHeight(managedNode);
Expand All @@ -125,16 +124,14 @@ private async Task ReevaluateNode(
var now = DateTimeOffset.UtcNow;
var windowStart = now - TimeSpan.FromDays(Constants.ROUTING_ENGINE_FLOW_WINDOW_DAYS);

// Every channel the node holds gets state, including channels shared with another managed
// node: routing state is per (channel, managed node), and each side's local balance, flow
// history and fee policy are its own. Deduping these to the initiator left the other side
// blind to its own depleted channels, so they never became rebalance destinations.
foreach (var lndChannel in listResp.Channels)
{
try
{
// Canonical ownership rule — a channel between two managed nodes is owned by one side.
if (!ChannelOwnershipHelper.IsOwnedByManagedNode(lndChannel, managedNodes))
{
continue;
}

// Act only on a channel we have a confirmed, open DB row for (O(1) lookup by scid).
if (!openChannelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel))
{
Expand Down Expand Up @@ -169,7 +166,7 @@ private async Task ReevaluateChannel(
/ Math.Max(1, lndChannel.LocalBalance + lndChannel.RemoteBalance);

// Seed EmaLocalRatio with the first observation on insert — no 0.5 cold-start bias.
var state = await _routingStateRepository.GetByChannelId(dbChannel.Id)
var state = await _routingStateRepository.GetByChannelIdAndNode(dbChannel.Id, managedNode.PubKey)
?? new ChannelRoutingState
{
ChannelId = dbChannel.Id,
Expand Down Expand Up @@ -229,6 +226,6 @@ private async Task ReevaluateChannel(
state.LastKnownUptime = lndChannel.Uptime;
state.LastEvaluatedAt = now;

await _routingStateRepository.UpsertByChannelId(state);
await _routingStateRepository.UpsertByChannelAndNode(state);
}
}
Loading
Loading