offline: let a replay report that it was never delivered - #354
Merged
Conversation
`SyncWorker::ReplayFunction` returns `bool`, whose only two outcomes are "remove it" and "charge one attempt". There was no way to say *"this never reached the server"*, so a transport failure and a server-side rejection were charged to the same 5-attempt budget — and that budget is durable, written back through `IOfflineQueue::setAttempts()`. Five reconnect flaps therefore exhausted the budget of every queued item and dropped them all, through the same `DeadLetterSink` call and the same user-facing "N changes could not be synced" state a genuine rejection produces. Work the server never saw was reported to the user as work that could not be applied, and the payload was gone unless the host's sink persisted it. Two shipped conditions make that reachable rather than theoretical: `ReconnectCoordinator::onOnline()` holds its mutex for the whole retry loop, so a flap back offline cannot preempt an in-progress replay; and nothing in the framework wires a `NetworkMonitor` transition to `SyncWorker::stop()`. Adds `ReplayOutcome` (`Succeeded`/`Rejected`/`Undelivered`) and a `DetailedReplayFunction` constructor overload taking it. Only `Rejected` spends an attempt; `Undelivered` touches neither the in-memory count nor `setAttempts()`, since advancing a durable budget on one side alone would still walk the item towards the sink across a restart. `SyncResult` gains `undelivered`. This is the distinction the count is spent on, not a knob on the count: the hard-coded cap of 5 is a documented design decision and is unchanged. The boolean `ReplayFunction` keeps its exact previous meaning — `false` maps to `Rejected`, deliberately not to `Undelivered`, because every existing caller means "this attempt failed" and re-reading that as undelivered would retry a poison payload forever. A throw is charged for the same reason: it reports failure but says nothing about delivery. The boolean overload adapts into the detailed one, so `run()` implements a single contract. The two overloads are unambiguous — `ReplayOutcome` is a scoped enum, so neither return type implicitly converts to the other. Closes #343 Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The whole-tree clang-format gate flagged the #343 workflow tests. No behaviour change; all 25 sync cases still pass. Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #343.
The gap
SyncWorker::ReplayFunctionreturnsbool. Its only two outcomes are "remove it" and "charge one attempt", so there was no way for a caller to say "this never reached the server" — a transport failure and a server-side rejection were charged to the same 5-attempt budget, and that budget is durable (written back throughIOfflineQueue::setAttempts()).Five reconnect flaps therefore exhausted the budget of every queued item and dropped them all, through the same
DeadLetterSinkcall and the same user-facing "N changes could not be synced" state a genuine rejection produces. Work the server never saw was reported as work that could not be applied, and the payload was gone unless the host's sink persisted it.Reproduced on
master, exactly the shape the issue asked this finding to carry before triage:Two shipped conditions make this reachable rather than theoretical:
ReconnectCoordinator::onOnline()holds its mutex for the whole retry loop, so a flap back offline cannot preempt an in-progress replay; and nothing in the framework wires aNetworkMonitortransition toSyncWorker::stop().The change
ReplayOutcome { Succeeded, Rejected, Undelivered }plus aDetailedReplayFunctionconstructor overload. OnlyRejectedspends an attempt.Undeliveredtouches neither the in-memory count norsetAttempts()— advancing a durable budget on one side alone would still walk the item towards the sink across a restart.SyncResultgainsundelivered.This is the distinction the count is spent on, not a knob on the count. The hard-coded cap of 5 is a documented design decision in
offline.mdand is unchanged; the issue explicitly does not dispute it, and neither does this PR.Compatibility — the boolean form is untouched
falsemaps toRejected, deliberately not toUndelivered. Every existing caller written againstboolmeans "this attempt failed", and silently re-reading that as undelivered would retry a genuinely poison payload forever — the exact failure the cap exists to bound. A throw is charged for the same reason: it reports failure but says nothing about delivery, and "I don't know" has to be charged.The boolean overload adapts into the detailed one, so
run()implements a single contract rather than two. The overloads are unambiguous:ReplayOutcomeis a scoped enum, so neither return type implicitly converts to the other.The framework side chose the seam over the alternative the issue offered — "document an obligation on the host to gate
run()on liveness". That pushes a correctness requirement onto every application, and the rung that motivated the finding shows how that goes: kanban never wiresNetworkMonitortostop(). A host obligation the flagship consumer did not discover is a trap, not a seam.Tests
Six cases in
tests/test_sync_worker.cpp: the five-flap reproduction; thatUndeliveredleaves the durable count untouched (via a queue recording everysetAttemptswrite); thatRejectedstill spends the budget and dead-letters on the 5th; that undelivered flaps interleaved between rejections do not shorten the budget; that the boolean form keeps its exact previous meaning; and that a throwing detailed replay is still charged.Mutation-tested, per AGENTS.md's "would this still pass if the feature did nothing?" — these tests reference a new enum, so compiling is not evidence. Neutering the
Undeliveredbranch so the feature does nothing:Exactly the three
Undeliveredcases fail; theRejected, boolean and throw cases correctly still pass, since the mutant does not change those paths.Verification
check_spec_citations.sh,check_test_type_names.sh,check_deprecated_markers.sh: pass.docs/spec/offline/offline.mdupdated:SyncResulttable, theSyncWorkerAPI table, and the retry/dead-letter rules.Deliberately not in this PR
examples/kanban/gui_lib/board_qml_bridge.cpp's replay lambda still returnsfalseon anyonErrorand so still cannot distinguish the two cases. AdoptingDetailedReplayFunctionthere is a rung change, not a framework one, and folding it in would mix two things — the framework seam it needs now exists.