Skip to content

Read users and roles through the record cache, ending user-cache refresh storms - #2776

Merged
kriszyp merged 11 commits into
mainfrom
fix/replicated-user-cache-refresh-storm
Sep 24, 2026
Merged

kriszyp merged 11 commits into
mainfrom
fix/replicated-user-cache-refresh-storm

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Authentication now reads hdb_user and hdb_role through the primary store's record cache instead of keeping a per-thread copy of both tables, so a user or role change needs no broadcast and there is nothing left to rebuild. The replicated-write refresh storm this PR was opened for has no mechanism left: the replication apply loop no longer knows about users at all (the userRoleUpdate flag and its signal are deleted).

For the human reviewer

  1. Framing (step-6 planning review): Framing-Verdict: better-alternative-exists, resolved mostly by adopting it. The reviewer endorsed removing the copy, then asked for durable versions instead of object identity, a torn-read guard, no silent audit enablement, and a subscription failure contract. All four are in. Two parts were overruled, each on a fact:

    • Reset LMDB's read txn on every lookup for strict post-ack freshness: lmdb-js abandons a read txn that other code still references (node_modules/lmdb/read.js:1062-1070), so each in-flight transaction would hold its own reader slot, and a reset costs ~1.5 µs plus a timer per lookup (measured below). Kept instead: LMDB lookups see another thread's commit from the next event-loop turn, like every other LMDB read. On a single busy turn, a request that arrives after an acknowledgement can still see the pre-change snapshot. RocksDB lookups have no such window.
    • A non-auditing after-commit observer for notifications: none exists. Cross-thread subscription delivery is driven by the audit log (resources/transactionBroadcast.ts addSubscription), so without auditing there is nothing to observe from.
  2. The requirement itself (your direction in this PR's thread): warranted. The storm was one instance of a design in which eight producers had to remember a broadcast, and a spurious broadcast cost an O(users + roles) scan on every thread. Removing the copy removes both the missed-signal and the extra-signal failure classes. The earlier per-commit mark plus coalescer is gone rather than kept as a second layer; utility/coalesceRefresh.ts survives only for the live-subscription sweep.

  3. Per-request cost, the price of reading records. Measured on this machine, unit environment, 50,000 iterations, RocksDB / LMDB:

    path before after
    authentication() Basic cache hit 2.3 / 1.5 µs 3.9 / 6.0 µs
    findAndValidateUser (session, mTLS, token user, local bypass) 0.4 / 0.3 µs 3.2 / 4.9 µs

    The cache-hit check is two native verifyVersion calls on RocksDB (0.4 µs each) and two validated reads on LMDB. A change costs nothing extra on any thread. Tell me if the hit-path cost is too high: the alternative is to drop the per-hit check and rely on onUserChange to flush the cache, which brings back the asynchronous window.

  4. Cross-worker completion semantics changed. alter_user, alter_role and the rest no longer await every worker. They do not need to for lookups: the first read after the commit on any worker sees it (subject to item 1 on LMDB). The push consumers (live-subscription revocation, MCP notifications) were never awaited on main either; they now fire from the table subscription.

  5. Audit-only push channel. On a node with logging.auditLog: false, live-subscription revocation falls back to its 30-second backstop sweep, and MCP sessions get no user-change list_changed. Each thread logs this once at info. Authentication itself is unaffected. main enables auditing on hdb_certificate implicitly (security/keys.ts subscribes unconditionally); I did not want to extend that to tables holding password hashes.

  6. Component principals are tracked by the Basic username. A component's server.getUser principal is re-verified once the hdb_user/hdb_role records named by the Basic username change. A component that maps that username to a different user, or takes the role from elsewhere, keeps its principal until authentication.cacheTTL (30 s) or server.invalidateUser(). main flushed the whole cache on any user event; adding that flush back through onUserChange is a one-liner if you want both.

  7. Deleted outright, not deprecated: the USER ITC event type, signalUserChange, UserEventMsg, serverHandlers.userHandler, setUsersWithRolesCache and getUsersWithRolesCache. harper-pro outside core has no callers (grep of its main). An external component calling them would fail at load.

  8. VERSION_REUSED records. An out-of-order replicated write (for example, create_authentication_tokens on two nodes) leaves a user record on a reused version until its next in-order write. Such a record is compared by _.isEqual of its value, so cache hits and the role memo still work, at the cost of one decode plus a deep compare per hit. The first version of this redesign looped forever on such a record; the review caught it, and a test now pins it.

  9. Backports. Fix user-cache refresh storm from replicated system-database writes (v5.1) (#2777) stays the narrow per-commit fix, which suits a patch line. This redesign is main (v5.3) only. v5.2 still has the original defects; backport scope there is your call.

  10. Deliberately unchanged: listUsers() still scans both tables through the legacy search, now only on cold paths (list_users, the last-super-user guard, and the first getSuperUser call). getSuperUser remembers the super user it found and re-checks it per call, instead of iterating a map.

  11. Rebased onto main's 07b2c9015 (the deferred-CF-reclamation merge). One textual conflict, in resources/Table.ts's import list: kept main's new dropColumnFamily/markDropInProgress/recordRetiredGeneration/sweepDroppedTableBlobs/storeNameFor/storeNamesFor imports, dropped UserEventMsg per this PR's removal of the ITC user broadcast. No semantic overlap — that work is table-drop generation reclamation, disjoint from user/role lookups.

  12. Two correctness bugs the post-rebase review found, both fixed. appendSystemTablesToRole now tolerates a role record with no permission (reachable only via a direct table write or a replicated write, since the operations API requires it) instead of throwing and taking down listUsers() — and with it assertActiveSuperUserRemains (drop_user, alter_user, alter_role, drop_role). readEntry now guards every user/role store read against an id that can't be a key: a username over LMDB's 1978-byte encoded-key limit (measuring the ordered-binary length, not UTF-8 bytes — escaped low control characters expand it) or a non-string/non-number id (a malformed stored role reference) previously reached store.getEntry and threw, turning an unauthenticatable credential into an internal fault instead of a clean 401.

  13. readUserEntries's consistency retry is now bounded at 50 attempts (MAX_USER_ENTRY_ATTEMPTS), falling back to "no such user" rather than looping the event loop forever under a sustained write storm on one user record. The alternative — throwing an internal fault instead of a credential rejection after exhaustion — is a one-line change if you'd rather distinguish the two; either fails closed.

  14. Three declined findings, left as-is:

    • The hdb_user/hdb_role table subscriptions (security/user.ts around L649) make every commit to system trigger an audit-log scan on a worker with no other system subscription. In the default config, TLS listeners already subscribe to system.hdb_certificate, so the added cost there is per-record dispatch only; a node with no TLS listener pays the new scan.
    • userView allocates a provenance object and a WeakMap entry on every principal, including the session-cookie path, which never calls isCurrentUser on it.
    • The uncached Basic-auth path pre-reads user/role versions before calling server.getUser (security/auth.ts around L263); with the default resolver this is redundant, since findAndValidateUser re-reads the same records. Both are cheap per-request costs, not correctness issues.
    • integrationTests/security/user-change-across-workers.test.ts's rejectedEverywhere samples 32 responses without identifying which worker answered (a 401 carries no threadId), so the revocation half of the test is probabilistic rather than exhaustive (~1e-4 chance of missing a worker, not zero).

Changes

Verification

Route: new unit tests against real hdb_user/hdb_role records (no stubs of the lookup path), an in-process replication apply-loop test, and a new multi-worker integration test.

— Claude Opus 5.5

Complexity: complicated

Origin — the dispatch brief this PR was written from

Fix user-cache refresh storm from replicated system-database writes

LIVE CONVERSATION about #2776.

You are answering a person, in a thread, one turn at a time. Every turn:

  1. Read the whole thread in this dispatch file's # Log — it is the conversation so far, and
    each of your previous turns is in it. Read the PR/issue and the code as needed.
  2. Answer the LAST message. Append your answer to # Log as your turn. Prose, not a report:
    they are talking to you, and a status template is not an answer.
  3. Set status: needs-input and stop. The thread stays open; their next message resumes it.

Each turn arrives as ASK (answer it, change nothing) or PERFORM (do it, then say what you did) —
the person chose which when they sent it, and the run's own prompt tells you which one this is.
Never infer it from the wording: an unrequested commit in the middle of a discussion and a polite
description of work that was supposed to happen are the two failures this exists to prevent.

Never mark a PR ready and never merge from this conversation.

Dispatch: task chat-pr-harper-2776-kriszyp · queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-kimi,codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-muse; rounds=11; full=3 @ 9eff242

Human-Review-Need: 4 (decisions: read-through-user-lookups, retry-exhaustion-as-unknown-user, unkeyable-id-as-absent, notification-requires-audit, remove-user-itc-surface, component-principal-provenance, impersonated-principal-shape) @ 9eff242

@kriszyp kriszyp added this to the v5.3 milestone Sep 24, 2026
gemini-code-assist[bot]

This comment was marked as resolved.

@kriszyp kriszyp changed the title Fix user-cache refresh storm from replicated system-database writes Read users and roles through the record cache, ending user-cache refresh storms Sep 24, 2026
kriszyp and others added 7 commits September 24, 2026 11:07
A replicated hdb_user/hdb_role write left a subscription-lifetime flag set, so
every later system-database commit on that subscription signalled a user
change, and the signal's .then had no rejection handler, so each failed commit
also raised an unhandledRejection. Every signal rebuilds the user cache on
every thread with full hdb_role + hdb_user scans, uncoalesced.

- Table.ts: mark the commit's own context on an accepted user/role write and
  signal only when that commit succeeds; attach the continuation only for
  marked commits, including writes staged late into an open begin_txn.
- user.ts: refresh through coalesceRefresh, one scan in flight per thread plus
  one trailing scan that starts after it, so a caller never resolves on a scan
  that read before its call.

Dispatch-Task: harper-replicated-user-cache-refresh-storm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3df4Rq3g3EEaskdCiyiuh
A source-applied put with no record content is reported and skipped by
_writeUpdate, so its commit stages nothing; marking it let a redelivered
malformed hdb_user put still rebuild every thread's user cache. Pin exactly one
signal per transaction however many user writes it carries, and drop the test
comments that restated names.

Dispatch-Task: harper-replicated-user-cache-refresh-storm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3df4Rq3g3EEaskdCiyiuh
A refresh that called the coalesced function synchronously saw `running`
still unset and started a second, overlapping run (measured maxActive 2 by
the v5.1 backport's review). Defer the refresh one microtask so `running` is
set before it runs, and drop a comment that restated its condition.

Dispatch-Task: harper-replicated-user-cache-refresh-storm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3df4Rq3g3EEaskdCiyiuh
… copy

Authentication kept a per-thread, pre-joined copy of every hdb_user row with its
hdb_role row and rebuilt it with a full scan whenever a USER ITC event arrived.
Eight producers had to send that event (user and role operations, refresh-token
updates, the replication apply loop), so a missed one left authorization stale
and a spurious one cost a scan on every thread; the replication storm was the
second kind.

Lookups now point-read the user by name and its role by id through the primary
store's record cache, which already stays coherent across threads. Nothing is
rebuilt and nothing has to be announced:

- readUserEntries re-checks the user's version after reading its role, so two
  unsnapshotted RocksDB reads cannot pair a user with a role from a different
  committed state.
- The role's system-table permissions are memoized per role version, and every
  returned user gets its own role and permission objects.
- auth.ts validates each authorizationCache hit against the user and role
  versions it was built from (isCurrentUser), so a password, role or activity
  change takes effect on the next request on every worker.
- onUserChange, fed by per-thread hdb_user/hdb_role subscriptions, notifies the
  consumers that hold a user already: live-subscription revocation (which now
  runs a trailing sweep for a change that lands mid-sweep) and MCP list-changed.
  It never subscribes to an unaudited table, since subscribe() would enable and
  persist auditing.

Deleted: usersWithRolesMap, setUsersWithRolesCache, getUsersWithRolesCache,
signalUserChange, the ITC userHandler and USER event type, UserEventMsg, the
apply loop's user/role mark, and the startup cache warm-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A user record a resequenced write left on a reused version made the
  re-read loop in readUserEntries spin forever: its version can never be
  confirmed, so every re-read looked like a change. Such a record is now
  accepted as read, and is never current for isCurrentUser or the role memo.
  A metadata-less record keeps a stable version until it is next written.
- A component's server.getUser principal in the authorization cache is
  checked against the user and role versions read for its name before it was
  resolved (trackUserRecords), instead of relying on the whole cache being
  cleared by a user-change notification.
- A failed hdb_user/hdb_role subscription is retried with backoff.
- The derived-role memo is bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A record a resequenced write left on a reused version was accepted without the
torn-read re-check, and never counted as current, so its holders re-resolved on
every request. Such a record is now stamped with its value instead of its
version: the re-check, isCurrentUser and the role memo compare values for it.

A listener that returns a non-promise value no longer logs a false failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the fix/replicated-user-cache-refresh-storm branch from c556abc to d98465c Compare September 24, 2026 17:41
kriszyp and others added 4 commits September 24, 2026 12:17
…roles

readUserEntries let a Basic-auth username over LMDB's 1978-byte key limit
reach store.getEntry, which throws instead of the intended clean 401; a
long username is now treated as no such user. Its user/role recheck loop
was also unbounded, so a sustained write storm on one user record could
spin the event loop; it now falls back to no-user after 50 attempts.

appendSystemTablesToRole assumed every hdb_role record had a permission
object. A role with no permission — reachable only via a direct table
write or a replicated write, since the operations API requires it — made
listUsers() throw, taking down assertActiveSuperUserRemains with it
(drop_user, alter_user, alter_role, drop_role) and getSuperUser's rescan.

Also drops two comments that narrate the diff rather than the code.

Found by round 7 of this branch's pre-push review (codex, gemini,
cursor-kimi, domain adjudication); both are new minor/nit findings, no
blockers survived adjudication.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YrgfpWWVHjruNRZA7fyAg
Dispatch-Task: pr-maint-f8a32eb70bff6d0d2b73f7c434344aa1
…guard

The previous guard checked Buffer.byteLength against LMDB's 1978-byte
limit, but primary-store keys are ordered-binary encoded and characters
U+0000-U+0003 escape to two bytes each. A username like '\u0001'.repeat(1000)
passed the byte-length check at 1000 bytes while its encoded key ran to
about 2000, so it still reached store.getEntry and threw.

Moved the guard into readEntry, mirroring resources/Table.ts's checkValidId
two-tier check (a fast path under 659 characters, else measure with
writeKey), so it covers every read through this module including
isCurrentUser's re-check of a previously-resolved username.

Also seed the malformed-role regression test through testUtils.seedUsers()
instead of writing straight to hdb_role, so afterEach restores it instead
of leaking it into later suites.

Found by round 8 of this branch's pre-push review (codex, gemini,
cursor-kimi, domain adjudication).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YrgfpWWVHjruNRZA7fyAg
Dispatch-Task: pr-maint-f8a32eb70bff6d0d2b73f7c434344aa1
…mments

keyTooLargeForStore returned false for any non-string id, so a role
reference set to an object or array by a direct or replicated write would
still reach store.getEntry and throw on LMDB. It now treats anything but
a string or number as unusable, same as the oversized-string case.

Drops three comments that restated the following line or answered a
reviewer rather than documenting a non-obvious invariant.

Found by round 9 of this branch's pre-push review (codex, gemini,
cursor-kimi, domain adjudication); the other majors it raised (retry
exhaustion returning no-user, systemStore's error status) were dropped or
downgraded by domain adjudication as intentional/factually wrong.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YrgfpWWVHjruNRZA7fyAg
Dispatch-Task: pr-maint-f8a32eb70bff6d0d2b73f7c434344aa1
Table.ts's checkValidId also validates object and bigint keys;
keyTooLargeForStore rejects them outright instead. Restated the comment
around what this guard actually covers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YrgfpWWVHjruNRZA7fyAg
Dispatch-Task: pr-maint-f8a32eb70bff6d0d2b73f7c434344aa1
@kriszyp
kriszyp marked this pull request as ready for review September 24, 2026 21:13
@kriszyp
kriszyp merged commit 530867c into main Sep 24, 2026
52 checks passed
@kriszyp
kriszyp deleted the fix/replicated-user-cache-refresh-storm branch September 24, 2026 21:13
kriszyp added a commit that referenced this pull request Sep 25, 2026
…v5.1) (#2777)

* Stop replicated system writes from storming the user cache (v5.1)

Backport of #2776 to v5.1. A replicated hdb_user/hdb_role
write left a subscription-lifetime flag set, so every later system-database
commit on that subscription signalled a user change, and the signal's .then
had no rejection handler, so each failed commit also raised an
unhandledRejection. Every signal rebuilds the user cache on every thread with
full hdb_role + hdb_user scans, uncoalesced.

- Table.ts: mark the commit's own context on a user/role write of a known
  source write type and signal only when that commit succeeds; attach the
  continuation only for marked commits, including writes staged late into an
  open begin_txn. v5.1 has no unknown-operation guard or valueless-put skip,
  so the mark checks the write type itself and a valueless put still counts.
- user.ts: refresh through coalesceRefresh, one scan in flight per thread plus
  one trailing scan that starts after it.

Dispatch-Task: harper-replicated-user-cache-refresh-storm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3df4Rq3g3EEaskdCiyiuh

* Keep coalesceRefresh to one run under re-entry; check the database first

A refresh that called the coalesced function synchronously saw `running`
still unset and started a second, overlapping run. Defer the refresh one
microtask so `running` is set before it runs. Test the database name before
the write-type Set lookup so application-database writes skip it.

Dispatch-Task: harper-replicated-user-cache-refresh-storm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3df4Rq3g3EEaskdCiyiuh

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant