Skip to content

Announce a write that changes nothing only to the writer that sent it - #6905

Merged
habdelra merged 8 commits into
mainfrom
cs-14226-a-realm-write-that-changes-nothing-still-broadcasts-an
Oct 22, 2026
Merged

habdelra merged 8 commits into
mainfrom
cs-14226-a-realm-write-that-changes-nothing-still-broadcasts-an

Conversation

@habdelra

Copy link
Copy Markdown
Contributor

This PR's base is the branch that announces a bulk write once and adds the X-Boxel-Realm-Events: none opt-out. It reworks the same batch-commit broadcast code, so review it first. This PR is the single commit on top of it.

What this changes

A commit that finds every file already holding the bytes it staged writes nothing and queues no index pass. Even so, it broadcast an incremental index event with no invalidations to every session room subscribed to the realm. For a public realm that means every registered user's room. Bulk writers resend unchanged content: the OpenRouter models sync sends ~600 operations in batches of 20, and the skills and homepage syncs upload every file. Each of their requests cost a full fan-out that told nobody anything.

The no-op branch of #commitBatchUnlockedInner now decides who the event is owed to:

  • A pass ran, so the event goes to every subscriber. A module that changed ahead of byte-identical instances runs a flush pass whose invalidations are real news. A shared pass this writer was chosen to announce has other writers standing down for it. Either way the event goes out exactly as before.
  • Nothing ran and the request named a client request id: only the writer's own session room. The host's AI-assistant tools key a wait on the incremental event that carries their request id (ToolService.trackAiAssistantCardRequest, awaited by the correctness check). A patch that changes nothing would otherwise stall that check until it times out. Only the writer's client can be holding such a wait, so the event goes to that one room. A writer the realm cannot identify still gets the event as every subscriber does.
  • Nothing ran and no request id was named: nothing is sent. No one can be waiting for an event they have no way to match.

A no-op commit already sent no incremental-index-initiation and no file update event. So the host's indexing waiter, which opens on an initiation and closes on an index event, stays balanced.

Delivery to named recipients

RealmAdapter.broadcastRealmEvent takes an optional RealmEventDelivery whose recipients narrows the send to those matrix users' session rooms. NodeAdapter filters the realm's session rooms by matrix user id. The host test adapter has no per-user rooms and ignores it.

Test plan

  • atomic-endpoints-test, a write that changes nothing announces its index event only to its own writer:

    • a no-op /_atomic update with no request id broadcasts nothing;
    • with a request id, exactly one incremental event goes out, carrying that id and addressed to the writer only;
    • a mixed batch whose module changes and whose instance does not still announces the flush's invalidations to every subscriber.

    Reverting the gate makes this test fail on its first assertion: the no-op write broadcasts an index event.

  • node-realm-test, delivers only to the named recipients when the broadcast names any: with no recipients every subscriber's room is sent to, and with recipients only theirs. A recipient with no session room receives nothing.

🤖 Generated with Claude Code

When every file in a commit is byte-identical to what is stored, the
commit queues no index pass, yet it still broadcast an `incremental`
event with no invalidations to every session room subscribed to the
realm — every user's room, for a public realm. Bulk writers that resend
unchanged content (the nightly OpenRouter sync, the skills and homepage
syncs) paid one fan-out per request for nothing.

Such a commit now announces nothing when the request named no client
request id, since nobody can be waiting for that event. When it did name
one, the event goes only to the writer's own session room: the host's
AI-assistant waiter keys its wait on the event carrying its request id,
and only the writer's client can be holding that wait. A module flush
that ran ahead of byte-identical instances, or a shared pass this writer
was chosen to announce, is still announced to every subscriber.

RealmAdapter.broadcastRealmEvent takes an optional delivery argument
whose `recipients` narrows the send to those matrix users' session rooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T23:43:55.531082Z 8c2a2e2 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] This review looked at four things: who can be waiting on a no-op write's index event, how passOwed interacts with the announcedByPeer stand-down in broadcastIncrementalInvalidationEvent, what initiatingUser holds on each front door into #commitBatchUnlockedInner, and every implementation of broadcastRealmEvent. The waiters checked were the host tool waiter, the store, file resources, base cards, boxel-cli, ai-bot and the test helpers. The realm-server and host suites were not run.

No blocking issues. Any pass behind the event still reaches every subscriber. The only listener keyed on a request id, ToolService.trackAiAssistantCardRequest, runs in the writer's own session, so the writer-only send reaches it.

  1. Name the writer the null-initiatingUser fallback is for, or drop the fallback. See the comment on the recipients spread in realm.ts. Non-blocking question.
  2. Follow-up: filter the recipients inside the session-room query instead of after it. See node-realm.ts. Non-blocking.

Adjacent, out of scope: fetchRealmSessionRooms matches the * row and per-user rows but not the users row that effectiveRealmPermissions honors. A user whose access comes only from users therefore gets no realm events at all, including the writer-only event here. The RealmEventDelivery comment ("every user who can read the realm") overstates delivery for the same reason. This is not this PR's bug. It's flagged for whoever touches that query next.

Comment on lines +5814 to +5816
...(!passOwed && initiatingUser
? { recipients: [initiatingUser] }
: {}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Which writer is the null-initiatingUser fallback for? When initiatingUser is null, this still sends the full fan-out to every subscriber. The comment above justifies that as "the only way to reach" a writer the realm cannot identify. I can't find such a writer that could be holding a wait:

  • A signed-in host always carries authenticatedUser.
  • An anonymous writer admitted by a policy grant does not arrive here as null. It arrives as the grant's acting user (opts.actor ?? opts.actingUser?.() in card-operations/coordinator.ts), so it gets the narrowed send to that account's room. The "cannot identify" wording doesn't describe that case.
  • A caller with no matrix session has no session room, so neither kind of delivery reaches it.
  • The remaining null cases are realm-internal writes.

If no such caller exists, gating on passOwed || (clientRequestId && initiatingUser) would keep an anonymous re-send of unchanged content out of every subscriber's room. That is the cost this change sets out to remove. If one does exist, please name it in the comment so the next reader doesn't remove the fallback.

Scope: this PR's own code. It's a question, not a demonstrated defect. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Kept the fallback, and named the writer it reaches in 39a8edf669. A world-writable realm admits a write that carries no Authorization header as anonymous (createRequestContext sets anonymous and leaves authenticatedUser unset), so initiatingUser is null. The host that sent it is still signed in to Matrix and can be holding an AI-assistant waiter keyed on its request id. With passOwed || (clientRequestId && initiatingUser) that waiter would sit until its timeout, because the realm has no room to narrow to. So that one case keeps the full fan-out. It is narrow (an anonymous write, to a world-writable realm, that changed nothing and named a request id), and none of the bulk writers take it.

Comment thread packages/realm-server/node-realm.ts Outdated
Comment on lines +458 to +463
if (opts?.recipients) {
let recipients = new Set(opts.recipients);
dmRooms = Object.fromEntries(
Object.entries(dmRooms).filter(([userId]) => recipients.has(userId)),
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] The narrowed send still loads every subscriber's session room and filters them in memory. On a public realm, fetchRealmSessionRooms returns one row per registered user, so a writer-only event pays the same read as the fan-out did and then sends a single event. Passing the recipients into the query (AND u.matrix_user_id = ANY(...) in db-queries/session-room-queries.ts) would make it a lookup of the named users only.

Follow-up, not this PR. The bulk writers behind this change name no request id and never reach this path. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Agreed that this is worth doing, and leaving it as a follow-up. NodeAdapter.broadcastRealmEvent and its session-room lookup are being rewritten into a paced per-process dispatcher in a separate open change. Pushing recipients into the fetchRealmSessionRooms query belongs in that new lookup, rather than in the loop it replaces. The narrowed path only runs for a no-op write that names a request id, which in practice is a host session, so it stays rare until then.

habdelra and others added 2 commits October 21, 2026 19:48
The fallback that sends a no-op write's event to every subscriber exists
for a request admitted without a user: an anonymous write to a
world-writable realm has no session room to narrow to. Say so, and
describe default delivery as what fetchRealmSessionRooms returns rather
than as every user who can read the realm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-per-file-so' of https://github.com/cardstack/boxel into cs-14226-a-realm-write-that-changes-nothing-still-broadcasts-an
@habdelra

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Addressed the review in 39a8edf669. The RealmEventDelivery comment now describes default delivery as the session rooms fetchRealmSessionRooms returns, not as every user who can read the realm. That query ignoring the users permission row is left out of scope as noted, since it predates this change. Both inline threads are answered.

@habdelra
habdelra requested a review from a team October 21, 2026 23:49
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Host Test Results

  1 files  ±  0    1 suites  ±0   16m 9s ⏱️ + 7m 53s
896 tests +371  895 ✅ +371  1 💤 ±0  0 ❌ ±0 
897 runs  +372  896 ✅ +372  1 💤 ±0  0 ❌ ±0 

Results for commit 067e075. ± Comparison against earlier commit 69f7643.

Realm Server Test Results

    1 files  ±0    361 suites  ±0   1h 49m 41s ⏱️ - 8m 19s
6 017 tests +3  6 017 ✅ +3  0 💤 ±0  0 ❌ ±0 
6 080 runs  +3  6 080 ✅ +3  0 💤 ±0  0 ❌ ±0 

Results for commit 067e075. ± Comparison against earlier commit 69f7643.

…t-per-file-so' of https://github.com/cardstack/boxel into cs-14226-a-realm-write-that-changes-nothing-still-broadcasts-an
# Conflicts:
#	packages/realm-server/node-realm.ts
#	packages/realm-server/tests/node-realm-test.ts
@habdelra

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Merged the updated base branch (now including #6899's realm event queue) in ba6fdd3. Two files conflicted:

Verified locally: atomic-endpoints-test and node-realm-test pass 38/38 against the test stack.

@habdelra
habdelra changed the base branch from cs-14188-a-bulk-_atomic-write-broadcasts-one-realm-event-per-file-so to main October 22, 2026 14:59
# Conflicts:
#	packages/realm-server/tests/atomic-endpoints-test.ts
@habdelra

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] The base branch has merged, so this PR now targets main and has main merged in (067e075). The diff against main is now only this change.

atomic-endpoints-test.ts conflicted only because this PR's new test and the new same-JSON test on main landed at the same spot. Both tests are kept unchanged. realm.ts merged cleanly. The same-JSON change on main decides when a card write counts as unchanged. This PR decides who hears the index event when nothing changed. A write the same-JSON check skips takes the same no-op path.

Verified locally: atomic-endpoints-test and node-realm-test pass 40/40.

@habdelra
habdelra merged commit b60e4fb into main Oct 22, 2026
147 of 151 checks passed
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.

2 participants