Fix gap detection persistence and ApplyBatch validation for reconciliation - #3
Fix gap detection persistence and ApplyBatch validation for reconciliation#3mrdevrobot with Copilot wants to merge 5 commits into
Conversation
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
|
Tutto questo tema @copilot mo fa pensare che non abbiamo valutato una blockchain per la polo gentry |
There was a problem hiding this comment.
Pull request overview
This PR addresses two critical reconciliation issues in EntglDb: persistent gap detection to prevent repeated historical data requests after restarts, and validation to reject Put operations without payload to prevent oplog pollution.
Changes:
- Added
GapDetectionServiceandNodeSequenceTrackerto track highest contiguous sequences per node with persistent state seeding - Implemented validation in
SqlitePeerStoreandEfCorePeerStoreto reject Put operations without payload - Added comprehensive tests for gap detection and ApplyBatch validation scenarios
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/EntglDb.Core/Sync/NodeSequenceTracker.cs | Thread-safe tracker for highest contiguous sequence per node |
| src/EntglDb.Core/Sync/GapDetectionService.cs | Gap detection service with persistent state seeding to avoid repeated requests |
| src/EntglDb.Persistence.Sqlite/SqlitePeerStore.cs | Added validation to reject Put operations without payload in ApplyBatch |
| src/EntglDb.Persistence.EntityFramework/EfCorePeerStore.cs | Added validation to reject Put operations without payload in ApplyBatch |
| tests/EntglDb.Persistence.Sqlite.Tests/GapDetectionTests.cs | New test suite for gap detection persistence and seeding behavior |
| tests/EntglDb.Persistence.Sqlite.Tests/SqlitePeerStoreTests.cs | Added tests for ApplyBatch validation with and without payload |
| docs/GAP_DETECTION_FIXES.md | Documentation of gap detection fixes and usage examples |
| docs/SECURITY_SUMMARY.md | Security analysis of the changes with CodeQL scan results |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public async Task EnsureSeededAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| lock (_seedLock) | ||
| { | ||
| if (_isSeeded) | ||
| return; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| _logger.LogInformation("Seeding gap detection from persistent oplog data"); | ||
|
|
||
| // Get all oplog entries to analyze | ||
| var allEntries = await _store.GetOplogAfterAsync(new HlcTimestamp(0, 0, ""), cancellationToken); | ||
|
|
||
| // Group by node and find highest contiguous timestamp (using physical time as sequence) | ||
| var nodeGroups = allEntries | ||
| .GroupBy(e => e.Timestamp.NodeId) | ||
| .ToDictionary( | ||
| g => g.Key, | ||
| g => g.Max(e => e.Timestamp.PhysicalTime) | ||
| ); | ||
|
|
||
| _tracker.SeedFromPersistentState(nodeGroups); | ||
|
|
||
| lock (_seedLock) | ||
| { | ||
| _isSeeded = true; | ||
| } | ||
|
|
||
| _logger.LogInformation("Gap detection seeded with {NodeCount} nodes", nodeGroups.Count); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Failed to seed gap detection from persistent state"); | ||
| throw; | ||
| } | ||
| } |
There was a problem hiding this comment.
The EnsureSeededAsync method has a race condition. After checking _isSeeded outside the lock (lines 40-44), multiple concurrent callers could pass the check and proceed to seed simultaneously. The async operation (GetOplogAfterAsync) executes outside the lock, so multiple threads could all start seeding before any of them sets _isSeeded to true. This could cause redundant expensive database queries and incorrect seeding behavior.
Consider using SemaphoreSlim for async locking or restructuring the code to set _isSeeded before the async call begins, or use a double-check locking pattern that properly handles the async context.
| .GroupBy(e => e.Timestamp.NodeId) | ||
| .ToDictionary( | ||
| g => g.Key, | ||
| g => g.Max(e => e.Timestamp.PhysicalTime) |
There was a problem hiding this comment.
The seeding logic uses Max() to find the highest physical time per node, but this doesn't calculate the "highest contiguous" sequence as documented in the comments and method names. The comment on line 35 says "Calculates the highest contiguous sequence" and the class documentation mentions "tracking contiguous sequences," but the implementation simply finds the maximum timestamp.
This means if a node has timestamps [1000, 2000, 5000] with a gap at 3000-4000, this will seed with 5000, suggesting all entries up to 5000 are present when they're not. This defeats the purpose of gap detection since gaps within the existing oplog won't be tracked.
To properly track contiguity, the code should identify the actual highest contiguous sequence by sorting timestamps per node and finding where gaps begin.
| g => g.Max(e => e.Timestamp.PhysicalTime) | |
| g => | |
| { | |
| // Sort physical times and walk until the first gap to find the highest contiguous value. | |
| var orderedTimes = g | |
| .Select(e => e.Timestamp.PhysicalTime) | |
| .OrderBy(t => t) | |
| .ToList(); | |
| if (orderedTimes.Count == 0) | |
| { | |
| return 0L; | |
| } | |
| long lastContiguous = orderedTimes[0]; | |
| for (int i = 1; i < orderedTimes.Count; i++) | |
| { | |
| long current = orderedTimes[i]; | |
| // Allow duplicates and immediate successors as contiguous. | |
| if (current == lastContiguous || current == lastContiguous + 1) | |
| { | |
| if (current > lastContiguous) | |
| { | |
| lastContiguous = current; | |
| } | |
| continue; | |
| } | |
| // Any jump larger than 1 indicates a gap; stop at the last contiguous value. | |
| if (current > lastContiguous + 1) | |
| { | |
| break; | |
| } | |
| } | |
| return lastContiguous; | |
| } |
| using EntglDb.Core; | ||
| using EntglDb.Core.Sync; | ||
| using FluentAssertions; | ||
| using System.Text.Json; | ||
| using Xunit; | ||
|
|
||
| namespace EntglDb.Persistence.Sqlite.Tests; | ||
|
|
||
| public class GapDetectionTests : IDisposable | ||
| { | ||
| private readonly string _dbPath; | ||
| private readonly SqlitePeerStore _store; | ||
| private readonly GapDetectionService _gapDetection; | ||
|
|
||
| public GapDetectionTests() | ||
| { | ||
| _dbPath = Path.Combine(Path.GetTempPath(), $"test-gap-{Guid.NewGuid()}.db"); | ||
| _store = new SqlitePeerStore($"Data Source={_dbPath}"); | ||
| _gapDetection = new GapDetectionService(_store); | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| if (File.Exists(_dbPath)) | ||
| { | ||
| try { File.Delete(_dbPath); } catch { } | ||
| } | ||
| } | ||
|
|
||
| private static OplogEntry CreateOplogEntry(string collection, string key, long physicalTime, int logicalCounter, string nodeId, object? data = null) | ||
| { | ||
| JsonElement? payload = null; | ||
| if (data != null) | ||
| { | ||
| var json = JsonSerializer.Serialize(data); | ||
| payload = JsonDocument.Parse(json).RootElement; | ||
| } | ||
| return new OplogEntry(collection, key, OperationType.Put, payload, new HlcTimestamp(physicalTime, logicalCounter, nodeId)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GapDetection_ShouldSeedFromPersistentState() | ||
| { | ||
| // Arrange - Add some entries to the oplog | ||
| var entry1 = CreateOplogEntry("users", "user1", 1000, 0, "node1", new { Name = "Alice" }); | ||
| var entry2 = CreateOplogEntry("users", "user2", 2000, 0, "node1", new { Name = "Bob" }); | ||
| var entry3 = CreateOplogEntry("users", "user3", 1500, 0, "node2", new { Name = "Charlie" }); | ||
|
|
||
| await _store.AppendOplogEntryAsync(entry1); | ||
| await _store.AppendOplogEntryAsync(entry2); | ||
| await _store.AppendOplogEntryAsync(entry3); | ||
|
|
||
| // Act - Seed the gap detection | ||
| await _gapDetection.EnsureSeededAsync(); | ||
|
|
||
| // Assert - Should track highest timestamp per node | ||
| var node1Highest = _gapDetection.GetHighestContiguousTimestamp("node1"); | ||
| var node2Highest = _gapDetection.GetHighestContiguousTimestamp("node2"); | ||
|
|
||
| node1Highest.PhysicalTime.Should().Be(2000); | ||
| node2Highest.PhysicalTime.Should().Be(1500); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GapDetection_ShouldNotReRequestGapsAfterRestart() | ||
| { | ||
| // Arrange - Simulate first run: add entries and seed | ||
| var entry1 = CreateOplogEntry("users", "user1", 1000, 0, "node1", new { Name = "Alice" }); | ||
| var entry2 = CreateOplogEntry("users", "user2", 2000, 0, "node1", new { Name = "Bob" }); | ||
|
|
||
| await _store.ApplyBatchAsync(System.Linq.Enumerable.Empty<Document>(), new[] { entry1, entry2 }); | ||
|
|
||
| // First gap detection instance - seeds and updates | ||
| var gapDetection1 = new GapDetectionService(_store); | ||
| await gapDetection1.EnsureSeededAsync(); | ||
| gapDetection1.UpdateAfterApplyBatch(new[] { entry1, entry2 }); | ||
|
|
||
| var sequences1 = gapDetection1.GetAllSequences(); | ||
| sequences1["node1"].Should().Be(2000); | ||
|
|
||
| // Act - Simulate restart: create new gap detection service and seed from persistent state | ||
| var gapDetection2 = new GapDetectionService(_store); | ||
| await gapDetection2.EnsureSeededAsync(); | ||
|
|
||
| // Assert - Should not detect gaps for already synchronized entries | ||
| var hasGap = gapDetection2.HasGap("node1", new HlcTimestamp(2000, 0, "node1")); | ||
| hasGap.Should().BeFalse("entries up to 2000 were already synchronized"); | ||
|
|
||
| // Should detect gap for newer entries | ||
| var hasGapNewer = gapDetection2.HasGap("node1", new HlcTimestamp(3000, 0, "node1")); | ||
| hasGapNewer.Should().BeTrue("entries after 2000 are not yet synchronized"); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GapDetection_ShouldUpdateAfterBackfill() | ||
| { | ||
| // Arrange - Start with some entries | ||
| var entry1 = CreateOplogEntry("users", "user1", 1000, 0, "node1", new { Name = "Alice" }); | ||
| await _store.AppendOplogEntryAsync(entry1); | ||
|
|
||
| await _gapDetection.EnsureSeededAsync(); | ||
|
|
||
| // Simulate detecting a gap | ||
| var hasGap = _gapDetection.HasGap("node1", new HlcTimestamp(3000, 0, "node1")); | ||
| hasGap.Should().BeTrue(); | ||
|
|
||
| // Act - Backfill missing entries | ||
| var entry2 = CreateOplogEntry("users", "user2", 2000, 0, "node1", new { Name = "Bob" }); | ||
| var entry3 = CreateOplogEntry("users", "user3", 3000, 0, "node1", new { Name = "Charlie" }); | ||
|
|
||
| await _store.ApplyBatchAsync(System.Linq.Enumerable.Empty<Document>(), new[] { entry2, entry3 }); | ||
| _gapDetection.UpdateAfterApplyBatch(new[] { entry2, entry3 }); | ||
|
|
||
| // Assert - Gap should be filled | ||
| var hasGapAfter = _gapDetection.HasGap("node1", new HlcTimestamp(3000, 0, "node1")); | ||
| hasGapAfter.Should().BeFalse("gap was filled by backfill"); | ||
|
|
||
| var sequences = _gapDetection.GetAllSequences(); | ||
| sequences["node1"].Should().Be(3000); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GapDetection_ShouldTrackMultipleNodes() | ||
| { | ||
| // Arrange | ||
| var entry1 = CreateOplogEntry("users", "user1", 1000, 0, "node1", new { Name = "Alice" }); | ||
| var entry2 = CreateOplogEntry("users", "user2", 2000, 0, "node2", new { Name = "Bob" }); | ||
| var entry3 = CreateOplogEntry("users", "user3", 3000, 0, "node3", new { Name = "Charlie" }); | ||
|
|
||
| await _store.ApplyBatchAsync(System.Linq.Enumerable.Empty<Document>(), new[] { entry1, entry2, entry3 }); | ||
|
|
||
| // Act | ||
| await _gapDetection.EnsureSeededAsync(); | ||
| _gapDetection.UpdateAfterApplyBatch(new[] { entry1, entry2, entry3 }); | ||
|
|
||
| // Assert | ||
| var sequences = _gapDetection.GetAllSequences(); | ||
| sequences.Should().HaveCount(3); | ||
| sequences["node1"].Should().Be(1000); | ||
| sequences["node2"].Should().Be(2000); | ||
| sequences["node3"].Should().Be(3000); | ||
| } | ||
| } |
There was a problem hiding this comment.
The test coverage is missing a critical scenario: when the oplog contains non-contiguous timestamps for a node. For example, if node1 has entries at timestamps 1000, 2000, and 5000 (missing 3000-4000), the seeding should identify 2000 as the highest contiguous timestamp, not 5000. Without this test, the bug in the seeding logic (using Max instead of finding true contiguity) goes undetected.
| { | ||
| if (File.Exists(_dbPath)) | ||
| { | ||
| try { File.Delete(_dbPath); } catch { } |
There was a problem hiding this comment.
Poor error handling: empty catch block.
| try { File.Delete(_dbPath); } catch { } | |
| try | |
| { | |
| File.Delete(_dbPath); | |
| } | |
| catch (Exception ex) | |
| { | |
| // Best-effort cleanup: log and ignore failures when deleting the temporary database file. | |
| System.Diagnostics.Debug.WriteLine($"Failed to delete temp db file '{_dbPath}': {ex}"); | |
| } |
Description
Reconciliation had two critical issues: (1) gap detection re-requested historical entries after restart due to lack of persistent state, causing unbounded oplog growth; (2) Put operations without payload were written to oplog without document updates.
Type of change
Changes
Gap Detection with Persistent State
Problem:
MAX(SequenceNumber)approach without seeding causes repeated gap requests post-restart.Solution:
NodeSequenceTracker: Thread-safe tracking of highest contiguous timestamp per nodeGapDetectionService: Seeds from oplog on startup, updates afterApplyBatchApplyBatch Validation
Problem:
ApplyBatchAsync(Empty, oplogEntries)with Put operations lacking payload wrote oplog-only entries without document updates.Solution: Validate and reject Put without payload in both
SqlitePeerStoreandEfCorePeerStore:Last-Write-Wins conflict resolution preserved.
How Has This Been Tested?
Gap Detection:
ApplyBatch Validation:
Regression:
Checklist:
Original prompt
This pull request was created from Copilot chat.
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.