I ran into this while working on a manufacturing app.
One workstation could be showing old data while an ERP or another application had already changed the database.
The usual solution? Click Refresh.
Timers don't really solve it. Custom “something changed” tables don't work when another application writes directly to the database.
But the database already knows when something changed.
SQL Server has Change Tracking.
SQLite has PRAGMA data_version.
So I built DbSignal — a .NET library that turns those database capabilities into a simple change-feed API.
The interesting part wasn't making every database look identical.
It was being honest about what each database can actually tell you.
Two providers, both proven: SQL Server and SQLite.
PostgreSQL and MySQL are designed, but not implemented yet.
Two proven beats four promised.
🔗 GitHub: https://github.com/rahibkhan44/DbSignal
📦 NuGet: DbSignal.SqlServer / DbSignal.Sqlite
If you've ever had to tell users to “just refresh,” this might be useful.
Top comments (2)
The explicit
KeysChangedversusDatabaseChangedcapability boundary keeps caller expectations honest. One retry edge looks worth a two-batch regression test: withRetryFailedBatches=true, a failed handler leaves the persisted checkpoint unchanged, but the hosted service continues the sameawait foreach, while both providers advance their in-memory position before yielding. If the next batch succeeds, its checkpoint can be persisted past the failed batch, weakening the at-least-once promise. Breaking and reopeningReadAsyncfrom the stored checkpoint—or maintaining per-handler checkpoints—would preserve retry semantics. Wascontinueintended to rely on the provider re-yielding the current position?You're right, and I reproduced it.
The continue assumed the provider would re-yield the unacknowledged position. Neither does. Both advance lastSeen before the yield return, so the failed batch never comes back, and the next successful batch saves a checkpoint past it. Those changes are gone even after a restart. That's a real at-least-once violation.
My test missed it for the reason you'd guess: the fake feed yields one batch and stops, so there's no second batch to move the checkpoint. Two batches is the minimal repro.
One catch on break-and-reopen. If nothing has committed yet the store is empty, so it falls back to StartAt, which is Checkpoint.Now by default, and the batch gets skipped anyway. It would need the last committed position held in memory. It also does nothing for SQLite, which never persists a checkpoint, so RetryFailedBatches is already a no-op there.
I'm leaning toward retrying dispatch of the same in-memory batch with backoff and never advancing past an unacknowledged one. Works for both tiers, no reconnect, and blocking on a poison batch is what the option already promises.
Fix and your two-batch test going in now. Thanks for reading that closely.