Skip to content

Make concurrent sourcedFrom misses share one source call when nothing gets cached - #3417

Merged
kriszyp merged 16 commits into
mainfrom
fix/sourcedfrom-negative-miss-coalescing
Oct 27, 2026
Merged

kriszyp merged 16 commits into
mainfrom
fix/sourcedfrom-negative-miss-coalescing

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 21, 2026 •

Copy link
Copy Markdown
Member
Two Buttons meme where an engineer sweats between twenty messengers each walking to the tower and one messenger whose empty envelope is copied to twenty

⊙ Problem

Refs #1398.

Concurrent reads of one key on a sourcedFrom table coalesce only when the first read stores a record. When the source reports the record missing, or the read uses noCacheStore, 20 concurrent reads make 20 origin calls, one after another.

🤔 Is the requirement right?

Yes, with one exception. A response is not shared if its source set noCacheStore on its own context. That marks the response as per-request.

💡 Solution

  • Waiters on the same thread now take the first read's result, "not found" included, when the store has nothing valid.
  • Failed fills are not shared; each waiter retries as before.
  • A shared record must be plain data. Records with a stream, iterator, Headers or blob refetch: a response consumes or changes those.
  • An unstored noCacheStore record goes only to waiters that also asked for noCacheStore.
  • Waiters on other worker threads still refetch in turn.

🤔 Should concurrent no-store requests share one source answer?

Yes, as the task asked. A caller-dependent source must set noCacheStore itself; the cache already shares its other answers. Reverting is one condition.

⚖️ Alternatives

  • Share the leader's promise: resolves before the commit and leaks the leader's stale-on-error answers.
  • Cache a short-lived "not found" entry: changes caching policy and does nothing for noCacheStore.
  • Fresh decode per waiter: costs an encode on every shared fill.

🔧 Changes

✅ Verification

Suite numbers

Before the rebase, test:unit:resources 4519 passing, 2 failing; test:unit:main 7425 passing, 2 failing (all four pass alone); test:integration:all 2558 passing, 12 cancelled (6 Ollama models missing locally, 6 in sql-not-predicate.test.ts, which passes alone). check:design-docs fails on main too: resources/DESIGN.md is over its 1000-line budget, and this PR adds 4 lines. Tests hold each burst's first source call until the other 19 reads park on the record lock.

Unit cases

absent record → 1 call; no negative caching across bursts; noCacheStore → 1 call, nothing stored; no-store leader with a caching waiter → the waiter caches; source-set noCacheStore → 20 calls, writable and read-only; cached record → 1 call; failed call → retried, failure not shared; failure then absence → 2 calls; failure while preparing the record → not shared; Headers, stream body, passthrough stream, iterator, uncopyable record, symbol/hidden/hole/getter/dead-proxy shapes, over-budget record → not shared; nested plain data with a text body → shared; stale entry behind a no-store leader → caching waiter refetches; 0/-0 kept apart.

Framing-Verdict: better-alternative-exists (23a399dda158) — adopted: the existing lock waiter callback carries the fill.

PR QA: skipped @ 88c71ad — dispatch unavailable (no worker token in this session).

Docs: no companion needed; no public API or configuration change.

🤖 Generated with Claude Code (Claude Opus 5.5; rebased and updated by Claude Sonnet 5.5); posted via @kriszyp.

https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z

Release target: main — a performance fix with no known backport need; it changes shared source-fill behavior

Related PRs: #3490 overlaps (same lock-release and waiter code in getFromSource), 9 others independent
Complexity: medium

Deferred findings

  • a44a4a185298b74f nit correctness — resources/Table.ts — outcome served when stored record later invalidated, evicted or expired — NOT FILED
  • ec56d2c461bfc09c nit documentation — resources/Table.ts — suggest comment that sourceFills is thread-local — NOT FILED
  • c4640d13051102da nit test-coverage — unitTests/resources/sourceFillCoalescing.test.js — source-identity guard and mixed-policy burst untested — NOT FILED
  • 996d31ce76294bfa nit security — resources/Table.ts — Concurrent no-store waiters receive the leader's unstored record without comparing requester identity — NOT FILED
  • bb1bbbbb0ec64391 nit reliability — resources/Table.ts — Synchronous throw between sourceFills.set and when(commitPromise) attachment orphans the fill map entry — NOT FILED
  • 1f2fab373441427f nit maintainability — integrationTests/resources/sourcedfrom-negative-miss-coalescing.test.ts — '; runtime resolves fine' in the ts-expect-error directive adds no constraint — NOT FILED
  • 1b3008c1343391d1 nit performance — resources/Table.ts — unstoredRecord counts a tombstone existingEntry, forcing caching waiters behind a no-store leader into one extra origin call — NOT FILED
  • 158e3ef374d7c494 nit test-coverage — unitTests/resources/sourceFillCoalescing.test.js — unit retry leaders rely on a fixed delay(10) to outlast re-parking waiters — NOT FILED
  • becf8ece6a59110d nit correctness — resources/Table.ts — waiters share the leader's live source object; Date and ArrayBuffer-view leaves skip own-property inspection, admitting an own one-shot iter — NOT FILED
  • 183bc5e8f8c1978e nit documentation — resources/DESIGN.md — note leaves resources/DESIGN.md at 998 of 1000 lines without pruning — NOT FILED

Dispatch: task harper-sourcedfrom-negative-miss-coalescing · queued by unknown · ran by claude/opus/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=gemini,codex,cursor-composer,cursor-muse; adjudicated=domain; blocked=muse(failed); declined=cursor-grok,cursor-kimi; rounds=17; full=2 @ 88c71ad

Review-Attention: study ~20m (critical: Table.ts; decisions: do-less-alternative, same-thread-only, no-store-sharing-semantics, scan-allowlist-vs-clone) @ 88c71ad

@kriszyp kriszyp added this to the v5.4 milestone Oct 21, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a mechanism in getFromSource() where concurrent same-key waiters on the same thread share a resolved source fill (even if nothing was stored, such as for absent records or under noCacheStore) to prevent duplicate origin requests. This change is accompanied by comprehensive unit and integration tests, as well as updated design documentation. The review feedback highlights a potential deadlock risk in resources/Table.ts where a synchronous error thrown by transaction() could escape the Promise executor, preventing the record lock from being released. Wrapping the transaction call in a try...catch block is recommended to handle synchronous failures safely.

Comment thread resources/Table.ts Resolved
kriszyp and others added 16 commits October 26, 2026 19:15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
A waiter on getFromSource's tryLock re-read the store after the leader's
unlock and called the source again whenever nothing valid was stored, so
concurrent misses whose source reported the record absent, or whose fill
ran under noCacheStore, made one origin call per waiter in sequence. The
leader now records its resolved entry once its fill commits with the write
kept, and a same-thread waiter serves it when the store still has nothing
valid. Failed and stale-on-error fills are not shared.

Refs #1398

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
A source that sets noCacheStore on its own context has marked its response
per-request, so that response is no longer shared with other waiters. An
unstored record goes only to a waiter that would not have stored it either,
so a caching waiter behind a no-store leader fills the cache itself. The
shared entry is now taken only at resolve(), so a fill that fails while
preparing the record (a non-extensible source object) is not shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
On a read-only node or a dropping table, noCacheStore is preset on the
source context, so a source's own per-request opt-out cannot be told apart
from it there; such fills are no longer shared. Widen the REST test's
origin hold so all reads overlap one fill on a slow runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
…t bursts

A table drop could abort a fill after its source write was allocated but
before it was staged, and the fill was then published as stored. The fill's
write now counts only once it is staged and its commit callback kept it.

The unit and REST tests now hold the first source call of a burst until
every other read has parked on the record lock, instead of relying on a
fixed delay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
Node 22's loose deepEqual does not treat null and undefined as equal, and
an absent record reads back as null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9ub6mcFSCB8x9He8FS35Z
Dispatch-Task: harper-sourcedfrom-negative-miss-coalescing
A record whose headers is a Headers-like object is not shared: the response
merge writes into it in place, which would leak one request's response
headers into another's. A no-store leader that finds the origin reports a
record absent while an older entry is stored now marks the fill unstored, so
a caching waiter refetches and stages the delete instead of keeping the
stale entry.

The REST test's gate deadline is now below the lock timeout so its
diagnostic wins, and the helper keeps the body of an unexpected status.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
The Headers-like guard left two shapes open: a raw one-shot stream body
without a status, and a record a response cannot copy (it assigns a Headers
onto the shared object, which the next request then merges into in place).
Eligibility is now an allow-list: a copyable record without a body or a
Headers-like headers, or an absent answer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
Replace the per-shape guards (body, Headers-like headers, passthrough
contentType, untracked records) with one invariant: a record goes to several
requests only if every value in it is plain data, scanned within a bounded
budget. That also covers a one-shot iterator held anywhere in the record,
which the shape guards missed. A record of plain data with a text body or
plain headers is now shared too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
The plain-data scan ran on every source fill and was read only when a waiter
found the store invalid, so it moves to that waiter and is memoized on the
fill; the default miss path pays nothing. The scan now walks own keys by
descriptor, so a symbol-keyed iterator, a non-enumerable body and a getter
all refuse sharing (without invoking the getter), and a sparse array is
bounded by its length.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
…ays by descriptor

A revoked proxy or a throwing trap made the scan throw inside the lock
waiter's callback, stranding that waiter; it now counts as not plain data.
Arrays go through the same descriptor walk, so an own Symbol.iterator, an
Array subclass or an index getter refuse sharing, and holes cost nothing
because only present keys are visited.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-b559cdc403fbab978032ffbc8604051f
@kriszyp
kriszyp force-pushed the fix/sourcedfrom-negative-miss-coalescing branch from 1e13349 to 88c71ad Compare October 27, 2026 01:37
@kriszyp
kriszyp marked this pull request as ready for review October 27, 2026 03:09
@kriszyp
kriszyp merged commit 105250e into main Oct 27, 2026
82 of 87 checks passed
@kriszyp
kriszyp deleted the fix/sourcedfrom-negative-miss-coalescing branch October 27, 2026 03:09
kriszyp added a commit that referenced this pull request Oct 27, 2026
- In Table.prototype.allowRead, route select=['*'] for attribute-scoped
  roles through the permitted-attribute expansion branch rather than
  falling back whenever a brace sub-select filters to empty, keeping
  child{*} and child[select(*)] consistent while returning {} when an
  unpermitted attribute is selected
- Add regression assertions for attribute-scoped child{*},
  child[select(*)], child{parentId}, and child[select(parentId)]
- Trim 10 wrapped lines in resources/DESIGN.md so the merged file stays
  under the 1000-line check-design-docs budget after #3417

Dispatch-Task: harper-3221-rebase-after-3415
Co-Authored-By: Gemini <noreply@google.com>
kriszyp added a commit that referenced this pull request Oct 27, 2026
* Fix REST select/relationship projections and ops-API object get_attributes

- F-337: Mark single-record responses uncacheable (`UNCACHEABLE_TIMESTAMP`)
  when `@relationship(from:)` resolves non-null foreign key IDs, mirroring
  `@relationship(to:)` so single-record `select` or `.property` traversal
  does not emit a stale parent ETag after related target rows change.
- F-338: Avoid double `transformForSelect` when `static loadAsInstance = true`
  by delegating projection to `Resource.get`, and guard null/missing records
  with `record?.[target.property]` when `static loadAsInstance = false` so
  missing, deleted, or expired records return 404 instead of throwing 500.
- F-342: Preserve single named sub-select objects (`?select(child{id,name})`)
  instead of unwrapping their inner array onto the parent query (#1415),
  reject bracketed condition syntax inside `select(...)` (`?select(rel[a=b])`)
  with a 400 `SyntaxViolation`, and use an identity transform in
  `Resource.transformForSelect` for `{ name }` selectors without a nested
  `select` array (e.g., `?select(id,children[sort(-id)])` on single-record GET).
- F-341: Allow nested `{ name, select }` objects in `search_by_hash` /
  `search_by_id` `get_attributes` validation and recursively validate nested
  relationship `select` entries against target table schemas across all four
  search operations while keeping nested JSON (`Any`) sub-keys unchecked.

Dispatch-Task: harper-rest-select-projection-fixes
Co-Authored-By: Gemini <noreply@google.com>

* Address pre-push review findings for select and get_attributes projection

Dispatch-Task: harper-rest-select-projection-fixes

Co-Authored-By: Gemini <noreply@google.com>

* Address round 2 pre-push review findings for select sub-queries and validation

- Pre-flatten sort clauses and pre-compute comparable keys once per array in Resource.applySubQuery, sharing convertToComparableKeys with Table.ts.
- Apply sub-query offset/limit slicing in Table.transformEntryForSelect, guard dbIterator.return/throw for array iterators, and await nested async promises in handleResolvedValue.
- Support class constructor schemas in searchValidator visited tracking and refine unit tests.

Dispatch-Task: harper-rest-select-projection-fixes
Co-Authored-By: Gemini <noreply@google.com>

* Validate limit/offset bounds and harden parser hang probe

Reject negative or non-integer limit and offset values in parseBlock
and searchValidator, normalize nested offset/limit bounds to
non-negative integers in Resource.transformForSelect and
Table.transformEntryForSelect, and harden the subprocess probe in
query-parse.test.js by stripping --inspect flags, calling
process.exit(0), and raising the timeout to 45s.

Dispatch-Task: harper-rest-select-projection-fixes
Co-Authored-By: Gemini <noreply@google.com>

* Update design notes, count comments, and test titles for review findings

Clarify in resources/DESIGN.md that Resource.transformForSelect caches
nested sub-transforms in a WeakMap keyed by the selector object and that
relationship.to projections are uncacheable via Table.search. Clarify in
resources/Table.ts and queryCount.test.js that non-finite or negative
limits on the count path apply to programmatic targets. Remove the stale
TODO in Resource.ts and replace internal tracker IDs in test titles with
GitHub issue references (#1415, #3136).

Dispatch-Task: harper-rest-select-projection-fixes
Dispatch-Task: pr-maint-70caff910b7e4cb82c05ffb1adba931f
Co-Authored-By: Gemini <noreply@google.com>

* Treat nested wildcard sub-select as identity in transformForSelect

In Resource.transformForSelect's nested-selector branch, treat a subSelect
of '*' or ['*'] as an identity transform ((v) => v), matching
Table.transformEntryForSelect (Table.ts:6877). Previously,
REST GET /Table/id?select(rel{*}) and search_by_hash with
get_attributes: [{ name: 'rel', select: '*' | ['*'] }] recursed with
transformForSelect(['*'], targetResource) and tried to read a literal '*'
property on the related record, returning an empty object instead of all
related fields.

Dispatch-Task: harper-3221-nested-star-select
Co-Authored-By: Gemini <noreply@google.com>

* Address late review findings on searchValidator, nested sort, and allowRead

- Hoist collectUnknownAttributes, getVisitedSet, and findMatchingTableAttribute to module scope in validation/searchValidator.ts and allocate visited WeakMap/WeakSet lazily only when an object selector is encountered.
- Reject nameless or malformed object selectors in searchValidator without throwing TypeError on non-function startsWith/toString or bypassing catalog checks via attribute: '*'.
- Avoid per-item/per-clause selectFromObject closure allocations in Resource.ts nested sort via readPropertyFromObject and specialize single-clause sorts.
- Preserve nested sort, limit, and offset on relationship selectors without an inner select in Table.prototype.allowRead for non-superusers.
- Name Resource.transformForSelect explicitly for the selector-keyed WeakMap sub-transform cache in resources/DESIGN.md.

Dispatch-Task: late-review-kriszyp_harper_3221-993c671a4573-aa6bb452
Dispatch-Task: pr-maint-70caff910b7e4cb82c05ffb1adba931f
Co-Authored-By: Gemini <noreply@google.com>

* Normalize braced relationship sub-selects in Table.prototype.allowRead

Keep braced property-set arrays (e.g. select(child{id,label})) in Table.prototype.allowRead when a non-superuser has table-level read permission on the related table instead of collapsing to the bare relationship name. Also deduplicate node:child_process/node:path imports in query-parse.test.js and migrate #js/server/threads/manageThreads to #src/ in restSelectProjectionFixes.test.js after rebasing onto main.

Dispatch-Task: harper-3221-rebase-after-3415

Co-Authored-By: Gemini <noreply@google.com>

* Address pre-push review findings after rebase onto #3571

- Normalize braced sub-selects (property.select = Array.from(property))
  before calling relatedResource.allowRead(user, property, context) and
  verify nested sort paths against related table read permissions in
  Table.prototype.allowRead
- Check query.parseError before calling resource.get(query) on
  loadAsInstance = true resources
- Share index-sorted parallel-key sortAndSliceArray helper between
  Resource.transformForSelect and TableResource.transformEntryForSelect
  for plain-JSON array sub-selects
- Defer UNCACHEABLE_TIMESTAMP on @relationship(from:) until after empty
  FK arrays short-circuit
- Avoid catching and doubling parseError in no-target parseQuery()
- Update resources/DESIGN.md and add regression tests

Dispatch-Task: harper-3221-rebase-after-3415
Co-Authored-By: Gemini <noreply@google.com>

* Fix scoped sub-select wildcard fallback and trim resources/DESIGN.md

- In Table.prototype.allowRead, route select=['*'] for attribute-scoped
  roles through the permitted-attribute expansion branch rather than
  falling back whenever a brace sub-select filters to empty, keeping
  child{*} and child[select(*)] consistent while returning {} when an
  unpermitted attribute is selected
- Add regression assertions for attribute-scoped child{*},
  child[select(*)], child{parentId}, and child[select(parentId)]
- Trim 10 wrapped lines in resources/DESIGN.md so the merged file stays
  under the 1000-line check-design-docs budget after #3417

Dispatch-Task: harper-3221-rebase-after-3415
Co-Authored-By: Gemini <noreply@google.com>

* fix(resources): preserve default permitted attributes when scalar select filters to empty

* Expand wildcard in-place in allowRead and deduplicate query-parse subprocess check

- Expand '*' in-place inside Table.prototype.allowRead's selectArray
  flatMap when attribute permissions are active so select(*, rel{...})
  and nested rel{*, subrel{...}} retain permitted scalar attributes
  alongside authorized relationship sub-selects
- Fold the 'select(id)?limit(1)' subprocess timeout probe into the
  existing '?' tokenizer child-process test in query-parse.test.js
- Replace PR-relative phrasing in resources/DESIGN.md with durable
  wording

Dispatch-Task: harper-3221-rebase-after-3415
Co-Authored-By: Gemini <noreply@google.com>

* fix(resources): handle * in transformForSelect, scope braced sub-select fallback, and cache sub-select transforms

---------

Co-authored-by: Gemini <noreply@google.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