Skip to content

feat(storage): run the ledger drift checks in the background - #2323

Open
atharrva01 wants to merge 1 commit into
LFDT-Panurus:mainfrom
atharrva01:feat/ledger-drift-checks
Open

atharrva01 wants to merge 1 commit into
LFDT-Panurus:mainfrom
atharrva01:feat/ledger-drift-checks

Conversation

@atharrva01

@atharrva01 atharrva01 commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2166

The drift checks (CheckTransactions, CheckUnspentTokens, CheckTokenSpendability) already existed but nothing on a running node ever called them, only integration test views did. This adds a background sweep, modeled on recovery and cleanup: runs on an interval, leader election through a PostgreSQL advisory lock, one sweep per store a node owns (owner over ttxdb, auditor over auditdb).

What changed:

  • Findings are now structured (checker, code, severity, tx/token id) instead of plain strings, persisted in a findings table keyed by a stable finding key. A repeated problem ages instead of getting re-reported, and closes once a sweep stops seeing it. A check that fails never closes its own findings, so an unreachable ledger can't look like a clean bill of health.
  • CheckUnspentTokens now resolves tokens against the ledger in batches instead of one call for everything followed by a linear scan per token.
  • New CheckLocalCompleteness check covers the direction the others can't: tokens the ledger says the node owns that never landed locally. That's the case that actually costs the owner money, so it runs in the background sweep.
  • The on-demand Check API on auditor/owner is unchanged in shape (still plain messages) but now backed by the same finding checkers, prefixed with severity and code.

Docs at docs/services/storage/checks.md, wired into docs/services/storage.md and docs/configuration.md.

Test plan

  • go test ./token/services/storage/... ./token/sdk/... (postgres, sqlite, race where applicable)
  • New unit tests for the checks manager/scheduler (leader election, sweep lifecycle, resolvable-checker semantics)
  • golangci-lint run clean on everything touched
  • Manually verified the findings upsert/aging against a real Postgres container

@Effi-S
Effi-S self-requested a review August 27, 2026 09:27
@AkramBitar
AkramBitar self-requested a review August 27, 2026 10:56
@AkramBitar

Copy link
Copy Markdown
Contributor

Three issues before this is ready to merge:

1. CI failure — TestTMSScopedProviderWiringIsIntact (blocking)

token/sdk/db/checks.go adds a new call to metrics.NewTMSProvider but the tokenDrivers list in token/services/metricsdoc/reference_test.go was not updated. Until it is, every metric this PR introduces is exported under the wrong name (panurus_core_common_metrics_ prefix instead of its own package). The test error message says exactly what to do: verify the new metrics against docs/development/metrics.md, then add token/sdk/db/checks.go to tokenDrivers.

2. Reintroduces the per-TMS lock-ID bug that PR #2085 just fixed

checks/config.go has a static defaultLockID = 0x74746b636865636b and an operator-configurable AdvisoryLockID. Two TMSes sharing a persistence configuration both derive the same constant, so only one wins the advisory lock per tick and the other silently skips its sweep forever — the exact same bug #2085 fixed for recovery and cleanup. The fix is the same: derive the lock ID from the fully-qualified table name at construction time and drop AdvisoryLockID from Config.

3. Interface conflict with PR #2085

checks.Storage declares AcquireRecoveryLeadership(ctx context.Context, lockID int64) — the signature #2085 is removing. The two PRs cannot both merge without a compile error. They need to be coordinated: either this PR adopts the no-parameter signature from #2085, or they merge in a defined order with the interface aligned before the second one lands.

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atharrva01

Thanks a lot for this PR.

See my comments.

Regards,
Akram

@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from 1c9492c to 0647194 Compare August 27, 2026 15:58
@atharrva01

Copy link
Copy Markdown
Contributor Author

Pushed a rebase onto main plus two fix commits addressing your review:

1. CI failure (blocking): fixed in 0647194. token/sdk/db/checks.go's NewTMSProvider call is now in tokenDrivers, plus a new "Checks: ledger drift" group in reference_test.go, the regenerated golden file, and the five panurus_core_common_metrics_storage_checks_* names documented in docs/development/metrics.md (with a pointer from checks.md's own table, which only had the bare names). Turned out tokenDrivers assumed every TMS-scoped call site used the identical literal expression the two driver.go files share, which doesn't hold for checks.go's differently-named locals, so it's a small struct per file now instead of a bare string list.

2. Per-TMS lock ID bug: fixed in e2afa1e, same shape as #2085 — derives the lock id from network/channel/namespace/role at manager construction instead of a node-wide constant, drops AdvisoryLockID from Config. Added TestManager_LockIDDistinctPerTMS covering both the per-TMS and per-role cases (owner vs auditor sweeps over the same TMS).

3. Interface conflict with #2085: still open, and I don't think it's fixable correctly from this side yet. checks.Storage.AcquireRecoveryLeadership(ctx, lockID) and ttxdb/auditdb's AcquireRecoveryLeadership(ctx, lockID) are the literal same method, so once #2085 drops the lockID param there, checks can't keep calling it with an arbitrary id at all — it needs its own leadership-acquisition path (mirroring what #2085 did for recovery/cleanup), which only makes sense to design against #2085's actual merged shape rather than guess at it now. Agree with your suggested sequencing: once #2085 merges I'll rebase this branch and align the interface then, rather than block on it now.

@atharrva01

Copy link
Copy Markdown
Contributor Author

Found and fixed the CI failure, pushed 9966417.

Root cause: `AsNamedChecker` (the downgrade from structured findings to the legacy plain-message Checker API) was including every finding regardless of severity. `SeverityInfo` is documented as "expected to resolve on its own" (a transaction the ledger has not caught up with yet, for example) and that's exactly what `CodeTxStatusUnavailable` reports when a status lookup fails right after a node restart. Since the plain-message contract has no way to carry severity, that info-level finding showed up as a plain "error" string to every legacy caller, including `CheckOwnerStore`'s "expect zero errors" assertion in the integration suite, which is why it failed across nearly the whole itest matrix rather than one flaky spec.

Fixed by dropping Info findings at that one downgrade point, so the structured findings table (and the background sweep) still see them, only the lossy plain-message path filters them out. Added unit tests for AsNamedChecker, which had none before.

@atharrva01
atharrva01 requested a review from AkramBitar August 27, 2026 20:45
@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @AkramBitar , CI passes now and the reviews are also addressed across this and all my other pr's Thanks :)

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: background ledger drift checks

Solid, well-tested feature — build, go vet, and all new tests pass at 3de2ff56c. I verified the generated upsert SQL against both backends (occurrences is correctly table-qualified, first_seen correctly excluded from the conflict update, and the Lt("last_seen", seenAt) predicate correctly avoids resolving rows the same sweep just wrote), and confirmed positional comparison in checkUnspentBatch is safe since both backends preserve request order.

That said, I found four defects that break the guarantee the service exists to provide — a checker that reports "clean" when it isn't, or records nothing at all. These are inline and marked Blocker:

  1. db/common/checks.go:430 — a ledger that can't answer Status causes the next sweep to close previously recorded critical findings.
  2. sql/common/findings.go:74 — a sweep with >~3000 findings exceeds the bind-parameter ceiling and persists nothing, repeatedly.
  3. services/checks/config.go:70 — any checks: block without an explicit enabled silently disables the sweep, contradicting its own godoc and the docs.
  4. db/common/checks.go:901 — a transient local-DB error is reported as a critical token_missing_locally.

Plus four more inline (one-by-one fallback unreachable, timeout covering the writes, nil metrics-provider panic, Stop() never wired) and one minor.

One finding with no line to anchor to

The auditor sweep is not leader-elected on Postgres. sqlcommon.NewAuditTransactionStore delegates to NewOwnerTransactionStore, which passes recoveryLeaderFactory = nil; AcquireRecoveryLeadership (sql/common/transactions.go:388) then returns noopRecoveryLeadership{}, true unconditionally. Both services/checks/manager.go:74 and docs/services/storage/checks.md:117 promise "only one replica sweeps a given store at a time, decided by a PostgreSQL advisory lock".

With N auditor replicas on one database, all N run the full sweep every interval — multiplying ledger traffic — and the slower replica's ResolveFindingsNotSeenSince can close findings the faster one just recorded. Either wire the advisory-lock factory into the audit store, or stop claiming election for that role. (The mechanism lives in code this PR doesn't touch, hence no inline anchor.)

Comment thread token/services/storage/db/common/checks.go
Comment thread token/services/storage/db/sql/common/findings.go
Comment thread token/services/storage/services/checks/config.go Outdated
Comment thread token/services/storage/db/common/checks.go Outdated
Comment thread token/services/storage/db/common/checks.go Outdated
Comment thread token/services/storage/services/checks/manager.go Outdated
Comment thread token/sdk/db/checks.go
Comment thread token/sdk/dig/sdk.go
Comment thread token/services/storage/db/driver/checks.go
@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from 22e27da to 1ab94db Compare August 31, 2026 13:22
@atharrva01

Copy link
Copy Markdown
Contributor Author

Fixed the auditor leader-election gap too, in 1ab94db. Postgres's NewAuditTransactionStore was building its store through sqlcommon.NewAuditTransactionStore -> NewOwnerTransactionStore, which hardcodes recoveryLeaderFactory: nil, so AcquireRecoveryLeadership always fell back to the no-op leadership. It now goes through NewTransactionStoreWithNotifierAndRecovery directly with NewAdvisoryLockFactory(), the same path the owner store already used. Added TestAuditRecoveryIntegration mirroring the existing owner-side TestRecoveryIntegration, confirms a second replica no longer acquires the lock while the first holds it.

All eight inline findings fixed too, replied on each thread with the specifics. Full make checks + make lint + go test ./token/... clean.

@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from 8b4fcc2 to a10f99d Compare August 31, 2026 15:09
@atharrva01
atharrva01 requested a review from AkramBitar August 31, 2026 18:16
@Effi-S
Effi-S force-pushed the feat/ledger-drift-checks branch from 926a70b to f004847 Compare September 2, 2026 15:21
@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from f004847 to 2c51942 Compare September 2, 2026 19:43
@atharrva01

Copy link
Copy Markdown
Contributor Author

@Effi-S @AkramBitar , a gentle ping on this

@atharrva01

Copy link
Copy Markdown
Contributor Author

@Effi-S , any changes here?

Comment thread token/services/storage/services/checks/manager.go
Comment thread token/services/storage/services/checks/manager.go
Comment thread token/sdk/dig/sdk.go
@atharrva01
atharrva01 requested a review from Effi-S September 12, 2026 18:31
@atharrva01

Copy link
Copy Markdown
Contributor Author

@Effi-S , ready for review

@AkramBitar

Copy link
Copy Markdown
Contributor

@Effi-S

Any update with this PR?

Regards,
Akram

Comment thread token/services/storage/db/common/checks.go
Comment thread token/sdk/db/checks.go
@Effi-S

Effi-S commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

@atharrva01,
Please squash commits, rebase, and fix the CI Failures

Comment thread token/services/storage/db/common/checks.go
Comment thread token/services/storage/services/checks/config.go
Comment thread token/services/storage/db/common/checks.go Outdated
Comment thread token/services/storage/services/checks/manager.go Outdated
Comment thread token/services/storage/services/checks/manager.go Outdated
@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from 30c3de5 to 9cb9488 Compare September 23, 2026 13:52
@atharrva01
atharrva01 requested a review from Effi-S September 23, 2026 13:57
@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @Effi-S , i have done the changes , you can take a look now

Comment thread token/services/storage/db/common/checks.go
Comment thread token/services/storage/services/checks/manager.go
Comment thread token/services/storage/services/checks/manager.go Outdated
Comment thread token/services/storage/services/checks/config.go
@Effi-S

Effi-S commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

@atharrva01, A few more medium and minor changes

@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from 9cb9488 to e9e17b4 Compare September 28, 2026 07:52
@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @Effi-S, pushed fixes for all four:

  1. QueryTokens' positional/nil-for-absent contract was already how both backends behave but wasn't stated anywhere, so I documented it directly on the interface method rather than trying to key by returned id, which the current signature has no way to do generically.
  2. Added a canceled outcome to SweepsTotal so a sweep Stop() interrupts no longer counts as failed, matching what logSweepError already does for the log line.
  3. report() now caps critical findings logged individually at 20 per sweep, with a one-line "N more omitted" after. Full detail is still in the findings table either way.
  4. Added an upgrade note to checks.md and to Config.Enabled's godoc calling out that this starts sweeping existing stores by default on upgrade.

Squashed back to one commit, rebased onto main.

@atharrva01

Copy link
Copy Markdown
Contributor Author

@Effi-S PTAL

@atharrva01
atharrva01 requested a review from Effi-S September 28, 2026 08:13
Comment thread token/services/storage/services/checks/manager.go
Comment thread token/sdk/db/checks.go
Comment thread token/services/storage/db/common/checks.go
@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @Effi-S, pushed 4298e9e for today's 3, plus replied on a few older threads from the 09-23 round that turned out to already be fixed in code but never got a reply. PTAL

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atharrva01,
Thank you for the work here. 🙏
There are some more finding here..

Comment thread token/services/network/common/rws/translator/translator.go
Comment thread token/services/storage/services/checks/manager.go
Comment thread token/services/storage/services/checks/manager.go
Comment thread token/services/storage/db/sql/common/transactions.go
@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @Effi-S, pushed fixes for all four (b65050f, 08f7e02). Sorry about the noise on the timeout thread, replied before I'd actually dug into it, fixed properly now. PTAL

@atharrva01
atharrva01 force-pushed the feat/ledger-drift-checks branch from 08f7e02 to 4b1223e Compare September 29, 2026 18:29
@atharrva01
atharrva01 requested a review from Effi-S September 29, 2026 18:34
Comment thread token/services/storage/db/common/checks.go
Comment thread token/services/storage/db/common/checks.go
Comment thread token/sdk/db/checks.go Outdated
Comment thread token/services/storage/services/checks/metrics.go Outdated
Comment thread token/services/storage/services/checks/manager_test.go Outdated
@AkramBitar

Copy link
Copy Markdown
Contributor

Hello @atharrva01,

Any updates with this PR?

Regards,
Akram

Adds a background sweep that periodically compares locally stored
token transactions and unspent tokens against the ledger, recording
findings for anything that drifted (a token missing on the ledger, a
transaction status that disagrees, content mismatches, etc). One
replica per store runs the sweep at a time via a PostgreSQL advisory
lock, findings are aged and auto-resolved once a problem stops being
observed, and the whole thing is a safety net that never fails the
owning service.

Includes the fixes from all review rounds:
- clamp the default timeout to scanInterval (and now any explicit
  timeout that exceeds it too), so a misconfiguration cannot fail
  owner/auditor service startup
- use FSC errors.Join instead of stdlib
- stop dropping info-severity findings on the legacy on-demand check
  API, and only elevate tx_status_unavailable to Warning for confirmed
  records so it still surfaces through the on-demand path without
  reintroducing the transient-lookup flakiness the info-severity filter
  was added for
- classic Fabric's token query now reports a missing token as a nil
  entry instead of erroring the whole call, matching fabricx, so
  checkUnspentOneByOne can tell a missing token apart from a read
  failure; GetTokenView and the interactive certifier backend both
  reject a nil entry explicitly since certification needs the token to
  actually exist, and the chaincode handler's own contract is
  documented rather than left implicit
- order transaction queries by (stored_at, tx_id) so the transaction
  walk dedupes a multi-movement transaction in O(1) memory instead of a
  set that grows with total ledger history; documented on the
  interface, since it's shared by every QueryTransactions caller, not
  just the checks walk
- reset the FindingsOpen gauge when leadership is not acquired or a
  leader sweep itself fails, so a replica that is not currently
  reporting current findings does not keep exporting a stale value
- FindingsOpen is now read back from storage (a GROUP BY count, not a
  row fetch) instead of derived from one sweep's own findings, so an
  inconclusive checker during an outage cannot make an older, still-
  open critical read as zero
- a failed sweeper.start is logged instead of failing CheckService,
  since the sweep is a safety net on top of the on-demand checks, not
  a precondition for them
- create the sweep ticker after the initial sweep completes so a slow
  first sweep cannot cause back-to-back sweeps
- document QueryTokens' positional/nil-for-absent contract on the
  interface itself, since checkUnspentBatch already relied on it
- give a sweep that Stop() interrupts its own "canceled" outcome on
  the SweepsTotal metric instead of counting it as failed
- cap how many critical findings report() logs individually per sweep,
  with a one-line "N more omitted" instead of flooding the log
- surface a checker that overran its timeout: FindingsService.Check
  swallows a per-checker DeadlineExceeded into a finding rather than an
  error, so the timeout warning now checks the sweep's own context
  directly instead of trusting the Checker's error return
- call out in the docs and in Config.Enabled that the sweep runs by
  default, so upgrading to a version carrying this service starts it
  on existing stores with no config change
- a token spent between CheckUnspentTokens' snapshot and
  checkUnspentBatch's local read (routine on a busy node) no longer
  fails the whole batch: getLedgerToken wraps ErrTokenNotFound, and the
  batch resolves one id at a time on that error, skipping ids spent
  since the snapshot instead of reporting check_failed over them
- sweeper.start now tracks a manager in s.managers under the same lock
  section as Start() itself, instead of after it returns, so a Stop()
  racing with startup can never miss a manager that did start and leak
  its goroutine (and, on postgres, its advisory-lock connection)
- fixed FindingsOpen's doc comment and Help string, stale since it
  started being read back from storage; regenerated the metrics golden
  file
- manager_test.go now uses the FSC errors wrapper instead of stdlib

Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @Effi-S, pushed fixes for all five (ccc41e7), squashed to one commit and rebased onto main to pick up the advisory-lock refactor that landed there in the meantime. Opened #2431 for the pagination one instead of fixing it here. PTAL

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AkramBitar, Please take a look

@atharrva01

Copy link
Copy Markdown
Contributor Author

hi @Effi-S @AkramBitar if this PR looks good now , can we move ahead with merge on this ?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ledger drift checks are never run on a live node

3 participants