# Phase 2C: RTT + Throughput-Aware Swarm Scheduler - Detailed Design > **Tasks**: T-406 to T-408 > **Branch**: `experimental/brainz` > **Dependencies**: Phase 1 (Multi-source downloads), T-400 to T-405 --- ## Overview The swarm scheduler optimizes chunk assignment in multi-source downloads by tracking per-peer performance metrics (RTT, throughput, error rates) and using a cost function to prioritize the best peers for high-priority chunks. This creates CDN-like performance from a heterogeneous set of Soulseek + overlay peers. --- ## 1. Per-Peer Metrics Collection (T-406) ### 1.1. Peer Performance Metrics Model ```csharp namespace slskd.Transfers.MultiSource { /// /// Performance metrics for a single peer in the swarm. /// public class PeerPerformanceMetrics { public string PeerId { get; set; } // Mesh peer ID or Soulseek username public PeerSource Source { get; set; } // Soulseek or Overlay // Connection metrics public double RttAvgMs { get; set; } // Exponential moving average public double RttStdDevMs { get; set; } // Standard deviation public DateTimeOffset? LastRttSample { get; set; } // Throughput metrics public double ThroughputAvgBytesPerSec { get; set; } // EMA public double ThroughputStdDevBytesPerSec { get; set; } public long TotalBytesTransferred { get; set; } public DateTimeOffset? LastThroughputSample { get; set; } // Reliability metrics public int ChunksRequested { get; set; } public int ChunksCompleted { get; set; } public int ChunksFailed { get; set; } public int ChunksTimedOut { get; set; } public int ChunksCorrupted { get; set; } // Hash mismatch // Computed rates public double ErrorRate => ChunksRequested > 0 ? (double)ChunksFailed / ChunksRequested : 0.0; public double TimeoutRate => ChunksRequested > 0 ? (double)ChunksTimedOut / ChunksRequested : 0.0; public double SuccessRate => ChunksRequested > 0 ? (double)ChunksCompleted / ChunksRequested : 0.0; // Sliding window state (for recent samples) public Queue RecentRttSamples { get; set; } = new(); public Queue RecentThroughputSamples { get; set; } = new(); // Metadata public DateTimeOffset FirstSeen { get; set; } public DateTimeOffset LastUpdated { get; set; } public int SampleCount { get; set; } } public enum PeerSource { Soulseek, Overlay } public class RttSample { public DateTimeOffset Timestamp { get; set; } public double RttMs { get; set; } } public class ThroughputSample { public DateTimeOffset Timestamp { get; set; } public double BytesPerSec { get; set; } public long BytesTransferred { get; set; } public TimeSpan Duration { get; set; } } } ``` ### 1.2. Database Schema ```sql -- Peer performance metrics (persisted for reputation) CREATE TABLE PeerPerformanceMetrics ( peer_id TEXT PRIMARY KEY, source TEXT NOT NULL, -- 'soulseek' | 'overlay' rtt_avg_ms REAL DEFAULT 0.0, rtt_stddev_ms REAL DEFAULT 0.0, throughput_avg_bytes_per_sec REAL DEFAULT 0.0, throughput_stddev_bytes_per_sec REAL DEFAULT 0.0, total_bytes_transferred INTEGER DEFAULT 0, chunks_requested INTEGER DEFAULT 0, chunks_completed INTEGER DEFAULT 0, chunks_failed INTEGER DEFAULT 0, chunks_timed_out INTEGER DEFAULT 0, chunks_corrupted INTEGER DEFAULT 0, first_seen INTEGER NOT NULL, last_updated INTEGER NOT NULL, sample_count INTEGER DEFAULT 0 ); CREATE INDEX idx_peer_metrics_source ON PeerPerformanceMetrics(source); CREATE INDEX idx_peer_metrics_throughput ON PeerPerformanceMetrics(throughput_avg_bytes_per_sec DESC); CREATE INDEX idx_peer_metrics_success ON PeerPerformanceMetrics(chunks_completed); ``` ### 1.3. Metrics Collection Service ```csharp namespace slskd.Transfers.MultiSource { public interface IPeerMetricsService { /// /// Get or create metrics for a peer. /// Task GetMetricsAsync(string peerId, PeerSource source, CancellationToken ct = default); /// /// Record an RTT sample. /// Task RecordRttSampleAsync(string peerId, double rttMs, CancellationToken ct = default); /// /// Record a throughput sample. /// Task RecordThroughputSampleAsync(string peerId, long bytesTransferred, TimeSpan duration, CancellationToken ct = default); /// /// Record chunk completion. /// Task RecordChunkCompletionAsync(string peerId, ChunkCompletionResult result, CancellationToken ct = default); /// /// Get ranked peers by performance. /// Task> GetRankedPeersAsync(int limit = 100, CancellationToken ct = default); } public class PeerMetricsService : IPeerMetricsService { private readonly ConcurrentDictionary metricsCache = new(); private readonly ILogger log; // Configuration private const int MaxRecentSamples = 30; // Sliding window size private const double EmaAlpha = 0.3; // Exponential moving average weight public async Task RecordRttSampleAsync(string peerId, double rttMs, CancellationToken ct) { var metrics = await GetOrCreateMetricsAsync(peerId, ct); lock (metrics) { // Add to recent samples metrics.RecentRttSamples.Enqueue(new RttSample { Timestamp = DateTimeOffset.UtcNow, RttMs = rttMs }); // Trim sliding window while (metrics.RecentRttSamples.Count > MaxRecentSamples) { metrics.RecentRttSamples.Dequeue(); } // Update exponential moving average if (metrics.SampleCount == 0) { metrics.RttAvgMs = rttMs; } else { metrics.RttAvgMs = (EmaAlpha * rttMs) + ((1 - EmaAlpha) * metrics.RttAvgMs); } // Compute standard deviation from recent samples metrics.RttStdDevMs = ComputeStdDev(metrics.RecentRttSamples.Select(s => s.RttMs)); metrics.LastRttSample = DateTimeOffset.UtcNow; metrics.SampleCount++; metrics.LastUpdated = DateTimeOffset.UtcNow; } await PersistMetricsAsync(metrics, ct); } public async Task RecordThroughputSampleAsync(string peerId, long bytesTransferred, TimeSpan duration, CancellationToken ct) { var metrics = await GetOrCreateMetricsAsync(peerId, ct); double bytesPerSec = bytesTransferred / duration.TotalSeconds; lock (metrics) { // Add to recent samples metrics.RecentThroughputSamples.Enqueue(new ThroughputSample { Timestamp = DateTimeOffset.UtcNow, BytesPerSec = bytesPerSec, BytesTransferred = bytesTransferred, Duration = duration }); // Trim sliding window while (metrics.RecentThroughputSamples.Count > MaxRecentSamples) { metrics.RecentThroughputSamples.Dequeue(); } // Update EMA if (metrics.TotalBytesTransferred == 0) { metrics.ThroughputAvgBytesPerSec = bytesPerSec; } else { metrics.ThroughputAvgBytesPerSec = (EmaAlpha * bytesPerSec) + ((1 - EmaAlpha) * metrics.ThroughputAvgBytesPerSec); } // Compute standard deviation metrics.ThroughputStdDevBytesPerSec = ComputeStdDev(metrics.RecentThroughputSamples.Select(s => s.BytesPerSec)); metrics.TotalBytesTransferred += bytesTransferred; metrics.LastThroughputSample = DateTimeOffset.UtcNow; metrics.LastUpdated = DateTimeOffset.UtcNow; } await PersistMetricsAsync(metrics, ct); } public async Task RecordChunkCompletionAsync(string peerId, ChunkCompletionResult result, CancellationToken ct) { var metrics = await GetOrCreateMetricsAsync(peerId, ct); lock (metrics) { metrics.ChunksRequested++; switch (result) { case ChunkCompletionResult.Success: metrics.ChunksCompleted++; break; case ChunkCompletionResult.Failed: metrics.ChunksFailed++; break; case ChunkCompletionResult.TimedOut: metrics.ChunksTimedOut++; break; case ChunkCompletionResult.Corrupted: metrics.ChunksCorrupted++; break; } metrics.LastUpdated = DateTimeOffset.UtcNow; } await PersistMetricsAsync(metrics, ct); } private double ComputeStdDev(IEnumerable values) { var valuesList = values.ToList(); if (valuesList.Count < 2) return 0.0; double avg = valuesList.Average(); double sumSquaredDiffs = valuesList.Sum(v => Math.Pow(v - avg, 2)); return Math.Sqrt(sumSquaredDiffs / valuesList.Count); } private async Task GetOrCreateMetricsAsync(string peerId, CancellationToken ct) { if (metricsCache.TryGetValue(peerId, out var cached)) { return cached; } // Load from database or create new var metrics = await LoadMetricsFromDbAsync(peerId, ct) ?? new PeerPerformanceMetrics { PeerId = peerId, FirstSeen = DateTimeOffset.UtcNow, LastUpdated = DateTimeOffset.UtcNow }; metricsCache[peerId] = metrics; return metrics; } } public enum ChunkCompletionResult { Success, Failed, TimedOut, Corrupted } } ``` ### 1.4. Integration with Multi-Source Downloads ```csharp namespace slskd.Transfers.MultiSource { public partial class MultiSourceDownloadService { private readonly IPeerMetricsService peerMetrics; private async Task DownloadChunkFromPeerAsync( VerifiedSource peer, ChunkRequest request, CancellationToken ct) { var stopwatch = Stopwatch.StartNew(); try { // Send chunk request var response = await SendChunkRequestAsync(peer, request, ct); stopwatch.Stop(); // Record RTT await peerMetrics.RecordRttSampleAsync(peer.PeerId, stopwatch.Elapsed.TotalMilliseconds, ct); // Validate chunk bool isValid = ValidateChunk(response, request); if (isValid) { // Record throughput await peerMetrics.RecordThroughputSampleAsync( peer.PeerId, response.Data.Length, stopwatch.Elapsed, ct); // Record success await peerMetrics.RecordChunkCompletionAsync(peer.PeerId, ChunkCompletionResult.Success, ct); } else { // Record corruption await peerMetrics.RecordChunkCompletionAsync(peer.PeerId, ChunkCompletionResult.Corrupted, ct); log.Warning("[SWARM] Chunk corrupted from peer {Peer}", peer.PeerId); } } catch (TimeoutException) { await peerMetrics.RecordChunkCompletionAsync(peer.PeerId, ChunkCompletionResult.TimedOut, ct); log.Warning("[SWARM] Chunk request timed out for peer {Peer}", peer.PeerId); } catch (Exception ex) { await peerMetrics.RecordChunkCompletionAsync(peer.PeerId, ChunkCompletionResult.Failed, ct); log.Error(ex, "[SWARM] Chunk request failed for peer {Peer}", peer.PeerId); } } } } ``` --- ## 2. Cost Function for Peer Ranking (T-407) ### 2.1. Cost Function Model ```csharp namespace slskd.Transfers.MultiSource { /// /// Cost function for ranking peers in swarm scheduling. /// public class PeerCostFunction { // Configurable weights public double ThroughputWeight { get; set; } = 1.0; // α (alpha) public double ErrorRateWeight { get; set; } = 0.5; // β (beta) public double TimeoutRateWeight { get; set; } = 0.3; // γ (gamma) public double RttWeight { get; set; } = 0.2; // δ (delta) // Penalty multipliers public double ZeroThroughputPenalty { get; set; } = 1000.0; public double HighErrorRatePenalty { get; set; } = 10.0; /// /// Compute cost for a peer. Lower cost = better peer. /// public double ComputeCost(PeerPerformanceMetrics metrics) { double cost = 0.0; // Component 1: Inverse throughput (lower throughput = higher cost) if (metrics.ThroughputAvgBytesPerSec > 0) { // Normalize to MB/s for readability double throughputMBps = metrics.ThroughputAvgBytesPerSec / (1024.0 * 1024.0); cost += ThroughputWeight / throughputMBps; } else { // No throughput data = very high cost cost += ZeroThroughputPenalty; } // Component 2: Error rate penalty cost += ErrorRateWeight * metrics.ErrorRate * HighErrorRatePenalty; // Component 3: Timeout rate penalty cost += TimeoutRateWeight * metrics.TimeoutRate * HighErrorRatePenalty; // Component 4: RTT penalty (higher RTT = higher cost) if (metrics.RttAvgMs > 0) { // Normalize RTT to seconds double rttSec = metrics.RttAvgMs / 1000.0; cost += RttWeight * rttSec; } // Component 5: Variance penalty (unstable peers get penalized) if (metrics.ThroughputStdDevBytesPerSec > 0 && metrics.ThroughputAvgBytesPerSec > 0) { double coefficientOfVariation = metrics.ThroughputStdDevBytesPerSec / metrics.ThroughputAvgBytesPerSec; cost += 0.1 * coefficientOfVariation; } return cost; } /// /// Rank peers by cost (best peers first). /// public List RankPeers(List peers) { return peers .Select(p => new RankedPeer { PeerId = p.PeerId, Metrics = p, Cost = ComputeCost(p) }) .OrderBy(rp => rp.Cost) .ToList(); } } public class RankedPeer { public string PeerId { get; set; } public PeerPerformanceMetrics Metrics { get; set; } public double Cost { get; set; } public int Rank { get; set; } // 1-based rank } } ``` ### 2.2. Configuration Options ```yaml multi_source: swarm_scheduler: cost_function: enabled: true # Cost function weights throughput_weight: 1.0 # α: Higher = prefer fast peers error_rate_weight: 0.5 # β: Higher = avoid unreliable peers timeout_rate_weight: 0.3 # γ: Higher = avoid slow/stalled peers rtt_weight: 0.2 # δ: Higher = prefer low-latency peers # Penalty multipliers zero_throughput_penalty: 1000.0 high_error_rate_penalty: 10.0 # Rebalancing rebalance_enabled: true rebalance_interval_seconds: 30 rebalance_cost_threshold: 2.0 # Rebalance if peer cost increases by this factor ``` --- ## 3. Cost-Based Scheduling Integration (T-408) ### 3.1. Enhanced Swarm Scheduler ```csharp namespace slskd.Transfers.MultiSource { public interface ISwarmScheduler { /// /// Assign chunks to peers using cost-based scheduling. /// Task>> AssignChunksAsync( List availablePeers, List pendingChunks, CancellationToken ct = default); /// /// Rebalance chunk assignments based on updated peer metrics. /// Task RebalanceAsync(MultiSourceDownloadJob job, CancellationToken ct = default); } public class SwarmScheduler : ISwarmScheduler { private readonly IPeerMetricsService peerMetrics; private readonly PeerCostFunction costFunction; private readonly ILogger log; public SwarmScheduler( IPeerMetricsService peerMetrics, IOptions options, ILogger log) { this.peerMetrics = peerMetrics; this.log = log; // Load cost function config var config = options.Value.MultiSource.SwarmScheduler.CostFunction; this.costFunction = new PeerCostFunction { ThroughputWeight = config.ThroughputWeight, ErrorRateWeight = config.ErrorRateWeight, TimeoutRateWeight = config.TimeoutRateWeight, RttWeight = config.RttWeight }; } public async Task>> AssignChunksAsync( List availablePeers, List pendingChunks, CancellationToken ct) { // Step 1: Get metrics for all peers var peerMetricsList = new List(); foreach (var peer in availablePeers) { var metrics = await peerMetrics.GetMetricsAsync(peer.PeerId, peer.Source, ct); peerMetricsList.Add(metrics); } // Step 2: Rank peers by cost var rankedPeers = costFunction.RankPeers(peerMetricsList); for (int i = 0; i < rankedPeers.Count; i++) { rankedPeers[i].Rank = i + 1; } log.Information("[SWARM] Peer ranking:"); foreach (var rp in rankedPeers.Take(5)) { log.Information(" #{Rank}: {Peer} - cost={Cost:F2}, throughput={Throughput:F1} MB/s, rtt={Rtt:F0}ms, errors={Errors:P0}", rp.Rank, rp.PeerId, rp.Cost, rp.Metrics.ThroughputAvgBytesPerSec / (1024.0 * 1024.0), rp.Metrics.RttAvgMs, rp.Metrics.ErrorRate); } // Step 3: Assign chunks using priority-aware strategy var assignments = new Dictionary>(); // Sort chunks by priority (descending) var sortedChunks = pendingChunks.OrderByDescending(c => c.Priority).ToList(); int peerIndex = 0; foreach (var chunk in sortedChunks) { // Assign high-priority chunks to best peers // Use round-robin within top N peers for load balancing int topPeerCount = Math.Min(5, rankedPeers.Count); var assignedPeer = rankedPeers[peerIndex % topPeerCount]; var peer = availablePeers.First(p => p.PeerId == assignedPeer.PeerId); if (!assignments.ContainsKey(peer)) { assignments[peer] = new List(); } assignments[peer].Add(chunk); peerIndex++; } log.Information("[SWARM] Assigned {ChunkCount} chunks to {PeerCount} peers", sortedChunks.Count, assignments.Count); return assignments; } public async Task RebalanceAsync(MultiSourceDownloadJob job, CancellationToken ct) { // Step 1: Identify underperforming peers var degradedPeers = new List(); foreach (var peer in job.ActivePeers) { var currentMetrics = await peerMetrics.GetMetricsAsync(peer.PeerId, peer.Source, ct); var currentCost = costFunction.ComputeCost(currentMetrics); // Check if cost has increased significantly since assignment if (peer.InitialCost.HasValue && currentCost > peer.InitialCost * job.Config.RebalanceCostThreshold) { degradedPeers.Add(peer); log.Warning("[SWARM] Peer {Peer} degraded: cost {Old:F2} → {New:F2}", peer.PeerId, peer.InitialCost, currentCost); } } if (degradedPeers.Count == 0) { return; // No rebalancing needed } // Step 2: Reassign chunks from degraded peers to better peers foreach (var degradedPeer in degradedPeers) { var pendingChunks = job.GetPendingChunksForPeer(degradedPeer); if (pendingChunks.Count > 0) { log.Information("[SWARM] Rebalancing {Count} chunks from peer {Peer}", pendingChunks.Count, degradedPeer.PeerId); // Cancel pending chunks for this peer await job.CancelPeerChunksAsync(degradedPeer, ct); // Reassign to other peers var otherPeers = job.ActivePeers.Except(degradedPeers).ToList(); var newAssignments = await AssignChunksAsync(otherPeers, pendingChunks, ct); foreach (var (peer, chunks) in newAssignments) { await job.AssignChunksToPeerAsync(peer, chunks, ct); } } } } } } ``` ### 3.2. Chunk Priority System ```csharp namespace slskd.Transfers.MultiSource { public class ChunkRequest { public long Offset { get; set; } public int Length { get; set; } public int Priority { get; set; } // 0-10, higher = more urgent public ChunkPriorityReason PriorityReason { get; set; } } public enum ChunkPriorityReason { Normal, // Regular chunk NearPlaybackHead, // For streaming: close to playback position EndOfFile, // EOF verification chunk Retry, // Previously failed chunk (higher priority) UserRequested, // User explicitly requested this range RescueMode // Rescue mode override (highest priority) } public static class ChunkPrioritizer { public static int ComputePriority(ChunkRequest chunk, MultiSourceDownloadJob job) { int priority = 5; // Base priority switch (chunk.PriorityReason) { case ChunkPriorityReason.UserRequested: priority = 10; break; case ChunkPriorityReason.RescueMode: priority = 9; break; case ChunkPriorityReason.NearPlaybackHead: priority = 8; break; case ChunkPriorityReason.EndOfFile: priority = 7; break; case ChunkPriorityReason.Retry: priority = 6; break; default: priority = 5; break; } // Boost priority for chunks near completion double completionRatio = job.BytesCompleted / (double)job.TotalBytes; if (completionRatio > 0.9) { priority = Math.Min(10, priority + 2); } return priority; } } } ``` --- ## 4. Implementation Checklist ### T-406: Per-peer metrics collection - [x] Define `PeerPerformanceMetrics` model - [x] Create database schema for metrics persistence - [x] Implement `IPeerMetricsService` interface - [x] Implement RTT sample recording with EMA - [x] Implement throughput sample recording with EMA - [x] Implement chunk completion recording - [x] Integrate metrics recording into `MultiSourceDownloadService` - [x] Add sliding window management (30 samples) - [x] Add unit tests for EMA calculations - [x] Add integration tests for metrics persistence ### T-407: Cost function for peer ranking - [x] Define `PeerCostFunction` class - [x] Implement `ComputeCost()` method - [x] Implement `RankPeers()` method - [x] Add configuration options to `Options.cs` - [x] Add cost function weight tuning CLI tool - [x] Add unit tests for cost calculations - [x] Add integration tests with mock peer data - [x] Document cost function parameters ### T-408: Cost-based scheduling integration - [x] Define `ISwarmScheduler` interface - [ ] Implement `SwarmScheduler.AssignChunksAsync()` - [ ] Implement `SwarmScheduler.RebalanceAsync()` - [x] Define `ChunkPriorityReason` enum - [x] Implement `ChunkPrioritizer` utility - [x] Add chunk priority to `ChunkRequest` - [x] Integrate scheduler into `MultiSourceDownloadService` - [x] Add rebalancing background task - [x] Add logging for scheduling decisions - [x] Add unit tests for chunk assignment - [x] Add integration tests for rebalancing - [x] Add performance benchmarks --- ## 5. Testing Strategy ### Unit Tests ```csharp [Fact] public void PeerCostFunction_LowThroughput_Should_IncreaseCost() { var costFunction = new PeerCostFunction(); var fastPeer = new PeerPerformanceMetrics { ThroughputAvgBytesPerSec = 5 * 1024 * 1024, // 5 MB/s ErrorRate = 0.0, RttAvgMs = 50 }; var slowPeer = new PeerPerformanceMetrics { ThroughputAvgBytesPerSec = 0.5 * 1024 * 1024, // 0.5 MB/s ErrorRate = 0.0, RttAvgMs = 50 }; double fastCost = costFunction.ComputeCost(fastPeer); double slowCost = costFunction.ComputeCost(slowPeer); Assert.True(slowCost > fastCost); } [Fact] public void SwarmScheduler_Should_AssignHighPriorityChunks_ToBestPeers() { var scheduler = new SwarmScheduler(mockMetrics, mockOptions, mockLogger); var chunks = new List { new() { Offset = 0, Length = 1024, Priority = 10 }, // High new() { Offset = 1024, Length = 1024, Priority = 5 }, // Normal new() { Offset = 2048, Length = 1024, Priority = 1 } // Low }; var assignments = await scheduler.AssignChunksAsync(peers, chunks, ct); // Verify high-priority chunk assigned to best peer var bestPeer = assignments.Keys.OrderBy(p => p.Cost).First(); Assert.Contains(assignments[bestPeer], c => c.Priority == 10); } ``` --- ## 6. Performance Considerations 1. **Metrics Caching**: Keep recent metrics in memory, persist periodically 2. **Cost Computation**: Pre-compute costs during idle time 3. **Rebalancing Throttling**: Don't rebalance more than once per 30 seconds 4. **Sample Window Management**: Use circular buffers for fixed-size windows 5. **Database Writes**: Batch metrics updates to reduce I/O --- ## 7. Tuning Guide ### Default Cost Function Weights - **High-throughput priority**: `throughput_weight = 2.0` - **Reliability priority**: `error_rate_weight = 1.0, timeout_rate_weight = 0.8` - **Low-latency priority**: `rtt_weight = 0.5` - **Balanced (default)**: `throughput_weight = 1.0, error_rate_weight = 0.5, timeout_rate_weight = 0.3, rtt_weight = 0.2` ### When to Rebalance - **Aggressive**: `rebalance_interval = 15s, rebalance_cost_threshold = 1.5` - **Conservative**: `rebalance_interval = 60s, rebalance_cost_threshold = 3.0` - **Default**: `rebalance_interval = 30s, rebalance_cost_threshold = 2.0` --- This design creates a sophisticated, CDN-like swarm scheduler that adapts to real-world peer performance!