fix(table): preserve error class in DBFind so transient DB failures don't evict live cache entries - #121
Conversation
Introduce a new recognizable error class, Unavailable, to represent transient or infrastructure-level failures (e.g. an unreachable database server) that are distinct from a genuinely absent resource (NotFound). Callers previously had no way to tell an absent row apart from an unreachable backend, since anything that was not NotFound/AlreadyExists carried the Unknown code. Unavailable gives the stack a first-class way to signal "retry me / do not treat as permanent" without conflating it with NotFound.
Companion helper for the new Unavailable error code, mirroring the existing Is* predicates (IsNotFound, IsAlreadyExists, ...). Lets callers cleanly distinguish transient/infrastructure failures from permanent ones.
interpretMongoError previously recognized only duplicate-key (AlreadyExists) and ErrNoDocuments (NotFound); every other failure - including an unreachable server, a server-selection timeout, or a request deadline - fell through as a raw error carrying the Unknown code. Map network errors and timeouts (mongo.IsNetworkError / mongo.IsTimeout, plus context deadline/cancellation) to the new errors.Unavailable class so that callers of the db layer can distinguish a genuinely absent document from a transient backend failure. This is the foundational fix that lets higher layers (e.g. table.CachedTable) avoid treating a DB blip as a NotFound. Behavior for existing recognized errors is unchanged.
…nt errors Two coupled defects in CachedTable, both stemming from DBFind flattening every FindOne error to errors.NotFound: 1. DBFind masked the real failure class. A genuinely absent row, an unreachable MongoDB, and a decode failure were all indistinguishable to callers - every one came back as NotFound. Consumers surfaced a permanent-sounding "not found" during a transient DB blip. 2. callback (the change-stream handler) calls DBFind and, on any error, took the IsNotFound branch and did delete(t.cache, *key). Because every error looked like NotFound, a transient DB error during a change event silently evicted a live cache row until the next reconciler sweep. Fix: - DBFind now preserves the underlying error class from the db layer (which classifies NotFound / Unavailable / AlreadyExists). Recognized errors are propagated as-is; only genuinely unknown errors are wrapped, and they are wrapped as Unknown rather than NotFound so a non-NotFound failure can never masquerade as a missing row. - callback only evicts the cached entry on a definitive NotFound. On a transient (Unavailable) or unknown error it logs and retains the existing cached value, letting the reconciler converge instead of dropping a live row on a DB blip. This removes the need for per-service workarounds (e.g. a Count re-probe to disambiguate a reported miss) in every integration consumer. Note: this commit is authored against the current upstream revision of cached_generic.go (WithFilter / WithWatchPipeline / preflight Count intact); those features are preserved unchanged.
|
Important Review available on request
Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Prabhjot-Sethi
left a comment
There was a problem hiding this comment.
Reviewed against a fresh checkout. Build and go vet are clean, and the table/reconciler test failures are pre-existing (they need a live MongoDB and fail identically on main) — not introduced here. Filed as #122.
The diagnosis is right and the direction is correct. Two things to address before this lands, both narrow.
1. Table.Find still has the identical bug
table/generic.go:303 is untouched and still flattens every FindOne error to NotFound:
err := t.col.FindOne(ctx, key, &data)
if err != nil {
return nil, errors.Wrapf(errors.NotFound, "failed to find entry with key %v: %s", key, err)
}Only CachedTable.DBFind was fixed. The non-cached Table is used directly in plenty of places, so downstream adapters told they can "drop Count-based re-probe workarounds" will find the workaround still necessary there. This is the same two-line change DBFind just received and belongs in this PR.
2. The FindMany/Aggregate hunks are inert (see inline comments)
The db/mongo.go classification is discarded one layer up. Details inline.
Scoped out, filed separately
- Test coverage — #122. The classification helpers are pure functions and cheap to cover, but the repo has no test infra to hang them on, and standing that up inside a behavior fix would bury the fix.
- Cause-chain preservation — #123.
errors.Wrapflattens the cause to a string andGetErrCodeuses a bare type assertion, so classification here is one-shot and unrecoverable if it misjudges. Touches everyWrapcall site; own blast radius.
Minor
context.Canceled → Unavailablereads as a mislabel. Caller-initiated cancellation (client disconnect, graceful shutdown) is not "datastore unreachable, safe to retry" — retrying a cancelled context is pointless, yet that is what theUnavailabledoc comment invites. Harmless incallback(usescontext.Background()), but wrong forFind(ctx)read-through.interpretMongoErrorstill useserr == mongo.ErrNoDocumentsrather thanerrors.Is— pre-existing, but this PR is already editing that function.
On cache staleness after a transient failure
Retry policy is correctly the caller's: NotifyCallback fires unconditionally, so the key reaches the pipeline and the controller decides via error-return or RequeueAfter. No argument there.
Worth a doc note though: the requeue re-runs Reconcile(key), not callback, and nothing in that loop re-runs DBFind. So a controller reading via t.Find(key) — the natural choice for a CachedTable — sees the same retained value on every retry and will not converge; only a controller calling DBFind explicitly gets fresh data. Suggest stating on callback/Find that after an Unavailable the cache may knowingly hold a stale entry, so reconcilers needing authoritative state must use DBFind. Contract clarification, no code change.
| } | ||
| if err = cursor.All(ctx, data); err != nil { | ||
| return err | ||
| return interpretMongoError(err) |
There was a problem hiding this comment.
This is inert as written — the class it produces is discarded one layer up.
cursor.All errors now become Unavailable here, but every caller in the table layer immediately overwrites that. table/cached_generic.go:428 (DBFindMany):
err := t.col.FindMany(ctx, filter, &data, opts)
if err != nil {
return nil, errors.Wrapf(errors.NotFound, "failed to find any entry: %s", err)
}Same at cached_generic.go:473 (DBFindManyWithOpts), generic.go:318 (FindMany), generic.go:361 (FindManyWithOpts). So a caller of DBFindMany still cannot tell an empty result from an unreachable Mongo — the exact bug this PR fixes for DBFind, still live on the list path. Net observable effect of this hunk is currently zero.
Two options: give those four call sites the same treatment DBFind got (preserve recognized classes, wrap only unknowns as Unknown), or drop the cursor.All hunks from this PR since nothing observes them yet. The first is preferable — it is the same fix, and leaving the list path lying is what forced the downstream Count re-probe in the first place.
Separately, worth fixing while here: cursor.All returns nil on an empty result set, so those NotFound wraps never described a genuine miss to begin with. They only ever fire on real failures, which makes NotFound there wrong in every case, not just the transient one.
There was a problem hiding this comment.
Fixed — took the preferred option. All four table-layer call sites now preserve the recognized error class and only wrap unknowns as Unknown, via a shared preserveErrClass(err, format, args...) helper added in table/generic.go:
DBFindMany(cached_generic.go),DBFindManyWithOpts(cached_generic.go)FindMany(generic.go),FindManyWithOpts(generic.go)
So the Unavailable this cursor.All hunk produces now actually reaches callers — an empty result is distinguishable from an unreachable Mongo on the list path. Commits: 77c77cf (generic.go) and 12675b2 (cached_generic.go).
Good catch on the second point too: since cursor.All returns nil on an empty result set, the old NotFound wraps only ever fired on real failures — NotFound there was wrong in every case, not just the transient one. That's why the fix is a straight class-preserving wrap rather than any empty-vs-error branching.
One disclosure: no Go toolchain is available in my sandbox, so I couldn't run go build/go vet locally — relying on CI for that verification.
| defer func() { _ = cursor.Close(ctx) }() | ||
| if err := cursor.All(ctx, result); err != nil { | ||
| return err | ||
| return interpretMongoError(err) |
There was a problem hiding this comment.
Same as the FindMany hunk above — Aggregate's only table-layer consumers re-wrap any error as errors.NotFound, so the Unavailable produced here never reaches a caller. Worth resolving both together.
There was a problem hiding this comment.
Resolved together with the FindMany hunk. Aggregate's table-layer consumers (FindMany/FindManyWithOpts and the DBFindMany* variants) now go through the shared preserveErrClass helper instead of re-wrapping as errors.NotFound, so the Unavailable produced here now reaches callers. Commits 77c77cf and 12675b2.
…aths Address review feedback on go-core-stack#121: - table/generic.go: Table.Find, FindMany, FindManyWithOpts now preserve the db-layer error class instead of flattening every failure to NotFound. Adds a shared preserveErrClass helper (recognized classes pass through; only unclassified errors are wrapped, as Unknown never NotFound). This fixes the same bug on the non-cached Table that DBFind received, so downstream adapters can drop Count-based re-probe workarounds on this path too. - table/cached_generic.go: DBFind now uses the shared helper; DBFindMany and DBFindManyWithOpts preserve the error class so the db-layer Unavailable classification on the list path is no longer discarded (the FindMany/Aggregate hunks are no longer inert). Adds cache-staleness contract docs on the CachedTable type, callback, and Find: after an Unavailable refresh failure the cache may knowingly retain a stale entry, so reconcilers needing authoritative state must use DBFind. - db/mongo.go: context.Canceled is no longer classified as transient/Unavailable (caller-initiated cancellation is not "safe to retry"); only DeadlineExceeded remains. Switch ErrNoDocuments comparison to errors.Is.
- generic.go: Table.Find/FindMany/FindManyWithOpts preserve the db-layer error class instead of flattening to NotFound (adds shared preserveErrClass helper). Fixes the same bug DBFind received, on the non-cached Table. - cached_generic.go: DBFind uses the shared helper; DBFindMany/DBFindManyWithOpts preserve the error class so the db-layer Unavailable classification on the list path is no longer discarded. Adds cache-staleness contract docs on CachedTable, callback, and Find (after an Unavailable refresh the cache may knowingly retain a stale entry; reconcilers needing authoritative state must use DBFind).
…s docs - DBFind now delegates to the shared preserveErrClass helper (behavior unchanged: recognized classes pass through, unknowns wrapped as Unknown). - DBFindMany/DBFindManyWithOpts preserve the db-layer error class instead of flattening to NotFound, so the Unavailable classification on the list path reaches callers (previously the db/mongo.go cursor.All hunks were inert). - Document the cache-staleness contract on CachedTable, callback, and Find: after an Unavailable refresh failure the cache may knowingly retain a stale entry, and since the reconciler requeue re-runs Reconcile (not callback/DBFind), reconcilers needing authoritative state must call DBFind explicitly.
|
Thanks for the thorough review, @Prabhjot-Sethi — all points addressed. Pushed as three commits on 1. 2. func preserveErrClass(err error, format string, args ...any) error {
if errors.GetErrCode(err) != errors.Unknown {
return err
}
return errors.Wrapf(errors.Unknown, "%s: %s", fmt.Sprintf(format, args...), err)
}The Minor: Minor: Doc: cache-staleness contract ( Left #122 (test coverage) and #123 (cause-chain preservation) as scoped out per your note. Disclosure: my sandbox has no Go toolchain, so I could not run |
Prabhjot-Sethi
left a comment
There was a problem hiding this comment.
Re-reviewed the three new commits against a fresh checkout of 12675b2. All prior findings are addressed, and well:
Table.Findno longer flattens toNotFound(table/generic.go:333).- All four list paths route through the shared
preserveErrClasshelper, so thecursor.Allhunks indb/mongo.goare no longer inert. context.Canceledexcluded from the transient set — with a caveat, below.err == mongo.ErrNoDocuments→base.Is(...). Nice, that was only a nit.- The staleness contract is documented on the type,
callbackandFind, including the "reconcilers needing authoritative state must callDBFind, notFind" consequence. That is exactly the gap I was worried about.
I also checked the blast radius of the Table.Find/FindMany class change: there are no in-core consumers of those outside tests, and the !IsNotFound(err) → panic guards in sync/lock.go and sync/provider.go behave identically before and after (a transient error was non-NotFound either way, just Unknown instead of Unavailable).
One item left, inline on db/mongo.go — the context.Canceled exclusion does not hold once the error is wrapped. Three-line fix.
On verification — please read, this one matters beyond this PR
One disclosure: no Go toolchain is available in my sandbox, so I couldn't run
go build/go vetlocally — relying on CI for that verification.
There is no CI in this repository. No .github/workflows, no GitLab/Travis/Circle config — and none in the history either. So nothing verified those commits; the fallback being relied on does not exist.
I ran it manually on 12675b2 and it is clean:
go build ./... → OK
go vet ./... → OK
gofmt -l . → (no output)
The table and reconciler test failures are the pre-existing no-MongoDB panics (log.Panicf in performMongoSetup) and reproduce identically on main — not caused by this PR, and tracked in #122.
No action needed on this PR; flagging it so "CI will catch it" does not become load-bearing on a repo that has none. If a toolchain cannot be made available in the sandbox, say so plainly as unverified rather than deferring to CI, and someone with an environment can run the gates.
With the context.Canceled ordering fixed, this is good to merge from my side. Tests (#122) and Unwrap() (#123) remain correctly deferred.
| // deadline exceeded surfaces on client timeouts and server-selection | ||
| // timeouts and is a genuine transient failure. context.Canceled is | ||
| // intentionally excluded (see the doc comment above). | ||
| if base.Is(err, context.DeadlineExceeded) { |
There was a problem hiding this comment.
The context.Canceled exclusion does not actually hold — it only catches a bare context.Canceled.
This check sits before the mongo.IsNetworkError/mongo.IsTimeout branch (line 59) and the net.Error As branch (line 63). Once the cancellation is wrapped — which is what happens when a cancelled operation tears down its connection and the driver surfaces it as a network error — one of those later branches reclassifies it as transient. So the doc comment above promises a guarantee the code does not deliver.
Probed it directly against this build:
bare context.Canceled isTransient=false
Canceled inside a net.Error isTransient=true ← contradicts the doc comment
Canceled inside a wrapped net.Error isTransient=true ← same
bare DeadlineExceeded (control) isTransient=true
plain net timeout (control) isTransient=true
(net.Error implementation wrapping context.Canceled via Unwrap, plus an fmt.Errorf("%w") layer on top.)
Fix is to make the exclusion explicit and put it ahead of the network checks:
if base.Is(err, context.DeadlineExceeded) {
return true
}
// caller-initiated cancellation is never transient, even when the driver
// surfaces it wrapped as a network error because the connection was torn
// down. Retrying a cancelled context is pointless.
if base.Is(err, context.Canceled) {
return false
}Low severity on its own — it mislabels a cancelled request as retryable — but worth closing because the surrounding comment reads as a firm guarantee, and this is precisely the classification callers are now being told to branch on.
There was a problem hiding this comment.
Fixed in d72810b. Added the explicit exclusion exactly as you laid out — base.Is(err, context.Canceled) → return false, placed before the mongo.IsNetworkError/mongo.IsTimeout and net.Error As branches:
if base.Is(err, context.DeadlineExceeded) {
return true
}
// caller-initiated cancellation is never transient, even when the driver
// surfaces it wrapped as a network error because the connection was torn
// down. This exclusion must sit ahead of the network/timeout branches
// below, otherwise a wrapped context.Canceled is reclassified as transient
// by mongo.IsNetworkError / the net.Error check and the doc comment's
// guarantee no longer holds. Retrying a cancelled context is pointless.
if base.Is(err, context.Canceled) {
return false
}So a context.Canceled wrapped as a network error now short-circuits to false before it can reach the transient branches — the doc comment's guarantee and the code now agree. Also dropped the stale inline "intentionally excluded (see doc comment)" note that was describing a guarantee the code wasn't actually delivering.
…network branches isTransientMongoError only excluded context.Canceled implicitly by not listing it. Because the DeadlineExceeded check was followed directly by mongo.IsNetworkError/mongo.IsTimeout and the net.Error As-branch, a context.Canceled wrapped as a network error (driver tears down the connection on cancellation) was reclassified as transient - contradicting the doc comment's guarantee. Add an explicit `base.Is(err, context.Canceled) -> return false` guard placed ahead of the network/timeout branches so caller-initiated cancellation is never treated as retryable, wrapped or not. Addresses review feedback from Prabhjot-Sethi on PR go-core-stack#121.
|
Pushed the last item as
On verification — correcting my earlier note, this one's on me. You're right, and I need to retract what I said. My "relying on CI for compile/vet verification" line was wrong on two counts:
So to be plain rather than deferring to something that doesn't exist: Thanks for catching the "CI will catch it" load-bearing assumption — noted for every future push on this repo. #122 (tests) and #123 ( |
…ers (#124) Error flattened its cause to a string, so an error tagged with a code stopped being inspectable the moment it crossed into this package: base.Is(err, context.DeadlineExceeded) and mongo.IsNetworkError(err) could never match again. GetErrCode compounded it by using a bare err.(*Error) type assertion, so a code was also lost as soon as the *Error was wrapped by anything else, fmt.Errorf("%w", ...) included. That became load-bearing with #121, which classifies transient mongo failures as Unavailable and tells callers to branch on IsUnavailable vs IsNotFound. Classification was one-shot: if isTransientMongoError misjudged a driver error, nothing downstream could recover the original to re-check it. - Error gains a cause field and an Unwrap method, so tagged errors take part in base.Is and base.As. Error() composes "message: cause", or returns whichever half is present. - GetErrCode walks the chain via base.As. The outermost *Error wins, so a later re-classification overrides an earlier one. - New WrapErr(code, err) and WrapErrf(code, err, format, v...) constructors preserve the cause. New, Wrap and Wrapf are untouched and carry no cause, so this is purely additive and all existing call sites keep their current behaviour. - Migrated the classification path added in #121 - the three interpretMongoError wraps and table.preserveErrClass - to the new constructors, which is where the chain actually matters. The remaining Wrap/Wrapf call sites can migrate incrementally. Both migrated shapes produce byte-identical messages to before, so there is no formatting regression. A transient mongo failure is now both classified Unavailable and still matchable as context.DeadlineExceeded. Tests cover cause preservation, Wrap/Wrapf still unwrapping to nil, code resolution through fmt.Errorf and through a custom wrapper, nested-code precedence, and the nil/untagged cases. They need no MongoDB and run in the default go test. Fixes #123
Problem
CachedTable.DBFind(table/cached_generic.go) wraps everyFindOneerror aserrors.NotFound:This flattens three very different conditions into one class — a genuinely absent row, an unreachable/timed-out MongoDB, and an undecodable document all look identical to callers.
The concrete harm is in the change-stream handler
CachedTable.callback, which callsDBFindand, on any error (alwaysIsNotFoundtoday), doesdelete(t.cache, *key). A transient DB blip during a change event therefore silently evicts a live cache row, surfacing a spurious miss until the next reconciler sweep.This was discovered while reviewing a downstream adapter that had to add a
Countre-probe workaround (classifyReportedMiss) to tell a real miss from a transient failure — logic that belongs in the library.Fix
Layered, minimal, and preserves all existing
NotFoundsemantics.errors/const.go— newUnavailable ErrCode = 6for transient/infrastructure failures (distinct fromNotFound, which means the item is genuinely absent).errors/errors.go—IsUnavailable(err)predicate, matching the existingIs*helpers.db/mongo.go—interpretMongoErrornow classifies transient failures (network error, driver timeout, server-selection timeout,context.DeadlineExceeded/Canceled) asUnavailablevia a newisTransientMongoErrorhelper. Duplicate-key →AlreadyExistsandErrNoDocuments→NotFoundare unchanged; unrecognized errors still pass through untouched.FindMany/Aggregatenow routecursor.Allerrors throughinterpretMongoErrortoo, for consistency.table/cached_generic.go—DBFindpreserves the error class from the db layer. Only genuinely unclassified errors are wrapped, and they are wrapped asUnknown(neverNotFound), so a non-miss failure can never masquerade as a missing row. A genuinely absent row still returnsNotFound.callbackevicts only on genuineNotFound(the delete scenario). OnUnavailable/unknown it retains the cached value and logs, letting the reconciler converge.Behavior preserved
Findcache-miss (non read-through) still returnsNotFound.DBFindfor a genuinely absent row still returnsNotFound.AlreadyExists/NotFoundmappings indb/mongo.goare unchanged.Notes
Count-based re-probe workarounds and rely onerrors.IsUnavailable/errors.IsNotFound.Flagged from downstream review (google-adapter GSA-0002).