Skip to content

fix(relay): coalesce verified profile publishes to one per macrotask - #2216

Merged
loganj merged 2 commits into
mainfrom
fix/profile-directory-coalesced-publish
Nov 3, 2026
Merged

loganj merged 2 commits into
mainfrom
fix/profile-directory-coalesced-publish

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Opening the members list of a ~1,175-member channel froze the desktop app for minutes. Client metrics from one click recorded main-thread long tasks of 65.5 s, 34.2 s, 15.8 s and 13.6 s in a row: 206 s of long tasks in a 6.5 min session, with 605,689 fold calls. The socket starved in the meantime. A foreground read timed out after 23.9 s, the live connection was declared unresponsive and reconnected, and the dialog showed "Relay read timed out" / "The member list could not load".

The trace shows 25 full 128-subject kind 30382 batches (1,290 events). Every verified-profile change called publish() synchronously inside the socket callback. Each publish() refolds every profile, runs the namesake scan and notifies every listener (views then re-render), and nothing yields between frames.

#2209 cut replay notifications to one per batch EOSE. Those were still synchronous, as were every live change, disconnect, reschedule and failure.

Change

profile-directory.ts: the verified directory's change callback now schedules one publish() per macrotask (setTimeout) instead of calling it inline. Changes within one task (a disconnect fan-out, a reschedule, one relay frame), plus frames already queued before the timer fires, coalesce into one publish. This does not strictly cap publishes across separate frames: live.ts delivers each WS frame as its own task, so frames arriving after the timer fires get their own publish. dispose() cancels a pending publish.

Not changed: kind 0 accept() and clear() still publish synchronously. session.ts and store.ts treat a profile notification as a synchronous boundary (subscribers can revoke or clear the session inside it), and those paths are already one publish per read or batch.

Behavior change: verified overlays reach snapshot() one macrotask after the relay frame instead of inside it. Hidden or occluded webviews throttle setTimeout, so while the app is backgrounded, verified changes (retractions, the switch to "cached" on disconnect) reach snapshot() later. Only readers outside the rendered UI, such as notifications, would see that delay.

Related

Validation

  • New test coalesces a burst of verified changes into one directory notification: fails on main, passes here.
  • Verified-profile and ProfilePanel tests now await the macrotask before reading verified state.
  • Full Vitest run locally: every file that failed under machine load (load average ~30) passed on an isolated rerun. The pre-push hook timed out on two unrelated UI tests (App.profile, ProfileSettings, 5 s timeouts); both files pass when run alone (49/49).
  • Not yet run in the desktop app.

Open before merge

🤖 Generated with Claude Code

each verified-profile change called publish() synchronously from the relay
socket callback, and every publish refolds the whole profile directory and
notifies every listener. a burst of kind 30382 frames (large roster, member
dialog, reconnect) ran those refolds back to back without yielding, starving
socket frames, input and paint for tens of seconds. verified changes now
schedule one publish per macrotask; kind 0 accept and clear stay synchronous.

Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kalvinnchau
kalvinnchau marked this pull request as ready for review November 3, 2026 00:19
@kalvinnchau
kalvinnchau requested a review from a team as a code owner November 3, 2026 00:19
verified.dispose() clears the verified directory, which schedules a flush; clearing the timer afterwards cancels it instead of relying on the closed guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account.

No actionable findings at head c35e9bd32759510714f3ac20ae4f5f18d410f8ce, base 1abd828add3c28b09fcbd260e7f80d4f7007174e. Reviewed the three-file PR patch from merge-base 0cdafb11270071e56b0d58546a421bd392dc5b3e and its callers at the head; the broader base-to-head diff includes base-branch drift.

Rocket, Mantis and Groot independently checked timer/performance lifecycle, reentrant clear/revocation and snapshot correctness, and profile consumers/error/retry behavior. The flush folds current verified state rather than a captured payload; kind-0 acceptance and full cache clear retain their synchronous notification boundaries. The closed guard prevents deferred publication after disposal.

Optional follow-up: cover pending-flush clear/disposal in the existing tests. Idle disposal can schedule one inert timer through verified.dispose() after cancellation; checking closed before arming would avoid that, but no user-impacting failure was found.

Source-only review: no tests, builds, app workflows or benchmarks were run, and CI was not assessed. The added test demonstrates a synchronous burst, not actual socket-frame grouping, paint fairness or bounded timer latency. Desktop performance and live member-list behavior remain unverified. This is a COMMENT review, not approval.

kalvinnchau added a commit that referenced this pull request Nov 3, 2026
…2235)

## Problem

Every profile-directory `publish()` asks the verified directory for a
snapshot. That re-projects every verified head with `verifiedProfile()`,
which re-reads its tags and builds a fresh object. The publish then
compares each new projection with the old one using `JSON.stringify`.
With 1,024 verified subjects, a single kind-0 update costs 6–8× as much
as the same update with no verified overlay.

## Change

- The verified directory keeps the last projection of each head event in
a `WeakMap`, keyed by the event and checked against the authority and
the checked flag. An unchanged head returns the same frozen object.
- The profile-directory comparison checks identity before falling back
to `JSON.stringify`.

## Evidence

Blox matrix: a profile directory with N kind-0 profiles, optionally
overlaid with N verified heads, receiving 300 single kind-0 updates.
Base and variant runs were interleaved, 7 runs on each of 2 Linux and 2
macOS workstations. Values are medians of 14 per OS.

| ms per kind-0 publish | Linux before | Linux after | Linux Δ | macOS
before | macOS after | macOS Δ |
|---|---|---|---|---|---|---|
| 1,024 verified | 3.191 | 0.804 | −75% | 1.769 | 0.482 | −73% |
| 512 verified | 1.604 | 0.372 | −77% | 0.873 | 0.240 | −73% |
| 1,024 kind-0 only | 0.400 | 0.335 | −16% | 0.270 | 0.233 | −14% |

After a discovery refresh (every 5 minutes while profiles are wanted).
Second Blox matrix on the revised patch, with base and variant runs
interleaved: 4 runs on each of 2 Linux and 2 macOS workstations. Values
are medians of 8 per OS. "First revision" is this PR before the
equal-refresh fix.

| 1,024 verified | Linux main | Linux first revision | Linux this PR |
Linux Δ vs main | macOS main | macOS first revision | macOS this PR |
macOS Δ vs main |
|---|---|---|---|---|---|---|---|---|
| ms per kind-0 publish, no refresh | 3.204 | 0.814 | 0.803 | −75% |
1.931 | 0.519 | 0.517 | −73% |
| ms per kind-0 publish after one equal refresh | 3.241 | 1.376 | 0.806
| −75% | 1.950 | 0.879 | 0.521 | −73% |
| CPU ms for 5 equal refreshes | 25 | 27 | 8 | −68% | 16.5 | 17 | 5.5 |
−67% |

What you gain: on a NIP-FI community, every profile change, and every
other republish such as a live verified change, costs about a quarter of
what it did. An equal periodic discovery refresh keeps the current
authority object, so the projections and the identity fast path survive
it.

The Blox runs used the first revision of this patch. Its projection
logic was inline instead of in the `project()` helper, and its behavior
was the same.

- `verified-profile.test.ts`: "reuses an unchanged head's projection
across snapshots" fails on `main`. "keeps an unchanged head's projection
through an equal discovery refresh" fails without the refresh change.
- `vitest run` on `src/features/relay`, `src/features/profiles` and
`src/features/messages`: 220 files and 5,001 tests pass, 4 skipped. `tsc
--noEmit` and biome are clean.

## Related

#2216 changes how often verified changes publish. This PR changes what
each publish costs, so they compose. Same probe on top of #2216, 4 runs
on each of 4 Linux and 4 macOS workstations. Values are medians of 16
per OS.

| On top of #2216 | Linux #2216 | Linux #2216 + this | Linux Δ | macOS
#2216 | macOS #2216 + this | macOS Δ |
|---|---|---|---|---|---|---|
| ms per kind-0 publish, 1,024 verified | 3.227 | 0.851 | −74% | 1.802 |
0.509 | −72% |
| CPU ms for a 1,024-event issuer republish | 373.5 | 144 | −61% | 241.5
| 109 | −55% |

## Tradeoff

The cache holds one small record per retained head event. It is a
`WeakMap`, so an evicted head's entry goes with it. A changed authority
(pubkey or `multipleIssuers`) or a checked-status flip re-projects the
head; an equal refresh keeps the existing authority object. The change
introduces no regressions or behavior changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>

@loganj loganj 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.

🤖 Approving for @loganj after a native before/after run (debug desktop, FI, #4things members dialog, one run per build).

base 0cdafb1 head aded36f
Stalls ≥50 ms after connect (count / total) 124 / 24.0 s 141 / 21.8 s
Longest stall 1.97 s 0.90 s
Verified-name changes → publishes 248 → 248 253 → 172
All publishes (count / time) 304 / 6.0 s 212 / 3.7 s
Freeze or "Relay read timed out" no no

Base did not reproduce the 65 s freeze, so that freeze is already gone on main (#2159, #2209). This PR is a measured improvement on top: about a third fewer verified publishes and a shorter longest stall. The remaining main-thread time is mostly signature verification (verify.read, 13–16 s per session). Counters were local-only instrumentation, not part of this PR.

@loganj
loganj merged commit 2a5ddb0 into main Nov 3, 2026
41 checks passed
@loganj
loganj deleted the fix/profile-directory-coalesced-publish branch November 3, 2026 14:13
kalvinnchau added a commit that referenced this pull request Nov 3, 2026
…#2234)

## Problem

A native NIP-FI socket renews its assertion every ~285 s: the lifetime
is capped at 300 s, minus a 15 s margin. `live.ts` reconnects quietly
and the connection stays reported `connected`, but `clearSocket()` ends
every sent sync with `disconnected: live connection lost`. Each
verified-profile (kind 30382) batch treated that as a failure:

- it cleared `checked` for its subjects;
- it republished the whole profile directory, and every name flipped
from checked to cached;
- the next `ensure` re-sent the batch, and its replay republished the
directory again at EOSE, even though nothing had changed.

With 1,024 verified subjects that is 8 batches. Each renewal therefore
caused 16 full republishes and 2,048 changed entries, and every checked
name dropped to cached for a moment.

## Change

- A batch that ends with `disconnected:` is re-sent at once with its
checked status kept, as a rebuilt batch already is. The decision waits
one microtask. An outage reports the connection down in the same task,
which stops every batch first, so an outage still clears status and
sends nothing extra.
- EOSE notifies only when it changed a head, a checked status or a
retraction. A replay that confirms what is shown is quiet.

## Evidence

Blox matrix: the real `createRelaySession` with the profile and verified
directories over a scripted live transport, 1,024 verified subjects.
Each renewal ends every sync as `clearSocket()` does, while states stay
`connected`. Base and variant runs were interleaved, 5 runs on each of 2
Linux and 2 macOS workstations. Values are medians of 10 per OS.

| Metric (5 renewals, ≈24 min of uptime) | Linux before | Linux after |
Linux Δ | macOS before | macOS after | macOS Δ |
|---|---|---|---|---|---|---|
| CPU (ms) | 327.5 | 22 | −93% | 194 | 27 | −86% |
| Profile notifications | 80 | 0 | −100% | 80 | 0 | −100% |
| Entries changed | 10,240 | 0 | −100% | 10,240 | 0 | −100% |
| Names checked after | 1,024 | 1,024 | 0 | 1,024 | 1,024 | 0 |
| Verified REQs / events | 40 / 5,120 | 40 / 5,120 | 0 | 40 / 5,120 | 40
/ 5,120 | 0 |

What you gain: every listener stops refolding on every renewal, about
12.6 times an hour on a NIP-FI relay, and verified names no longer
flicker from checked to cached. Wire traffic is unchanged.

The Blox runs predate the one-microtask deferral. Locally, the same
harness gives an identical renewal result (0 notifications, 1,024
checked). An outage scenario keeps `main`'s 40 REQs and clears status as
before.

- `verified-profile.test.ts`, new: "re-sends a batch a replaced socket
ended and keeps its check quiet while connected" fails on `main`. "does
not re-send a batch an outage ended" pins the outage path.
- `vitest run` on `src/features/relay`, `src/features/profiles` and
`src/features/messages`: 220 files and 5,002 tests pass, 4 skipped. `tsc
--noEmit` and biome are clean.

## Related

#2216 makes verified publishes one per macrotask. That reduces how many
republishes a renewal causes, but each ended batch still drops its names
to cached until the re-sent batch answers. This PR keeps them checked
and keeps a confirming replay quiet, so the two compose. Same Blox
harness, stacked on #2216, interleaved, 4 runs on each of 4 Linux and 4
macOS workstations. Values are medians of 16 per OS.

| 5 renewals @1,024 verified | Linux #2216 | Linux #2216 + this | Linux
Δ | macOS #2216 | macOS #2216 + this | macOS Δ |
|---|---|---|---|---|---|---|
| CPU (ms) | 74.5 | 21 | −72% | 64 | 27.5 | −57% |
| Profile notifications | 10 | 0 | −100% | 10 | 0 | −100% |
| Checked↔cached flips | 10,240 | 0 | −100% | 10,240 | 0 | −100% |
| Verified REQs | 40 | 40 | 0 | 40 | 40 | 0 |

Outage (5 disconnect/reconnect cycles) on #2216 with and without this
change: 10 notifications and 40 REQs in both, CPU 78 vs 78 ms on Linux
and 132.5 vs 131.5 ms on macOS. The outage path is unchanged.

## Tradeoff

- The directory infers a renewal from the documented `disconnected:`
reason and from the connection still being up after the current task. It
does not get an explicit signal from `live.ts`, which keeps the change
to one file.
- During an outage, batches now end through `connected(false)` alone.
That is one profile notification per outage instead of one per batch
plus one. The end state is unchanged.
- A relay CLOSED whose reason starts with `disconnected:` is now
reported as `error: relay closed the sync`, so only local socket loss
takes the renewal path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
…lock#2216)

## Problem

Opening the members list of a ~1,175-member channel froze the desktop
app for minutes. Client metrics from one click recorded main-thread long
tasks of 65.5 s, 34.2 s, 15.8 s and 13.6 s in a row: 206 s of long tasks
in a 6.5 min session, with 605,689 `fold` calls. The socket starved in
the meantime. A foreground read timed out after 23.9 s, the live
connection was declared unresponsive and reconnected, and the dialog
showed "Relay read timed out" / "The member list could not load".

The trace shows 25 full 128-subject kind 30382 batches (1,290 events).
Every verified-profile change called `publish()` synchronously inside
the socket callback. Each `publish()` refolds every profile, runs the
namesake scan and notifies every listener (views then re-render), and
nothing yields between frames.

block#2209 cut replay notifications to one per batch EOSE. Those were still
synchronous, as were every live change, disconnect, reschedule and
failure.

## Change

`profile-directory.ts`: the verified directory's change callback now
schedules one `publish()` per macrotask (`setTimeout`) instead of
calling it inline. Changes within one task (a disconnect fan-out, a
reschedule, one relay frame), plus frames already queued before the
timer fires, coalesce into one publish. This does not strictly cap
publishes across separate frames: `live.ts` delivers each WS frame as
its own task, so frames arriving after the timer fires get their own
publish. `dispose()` cancels a pending publish.

Not changed: kind 0 `accept()` and `clear()` still publish
synchronously. `session.ts` and `store.ts` treat a profile notification
as a synchronous boundary (subscribers can revoke or clear the session
inside it), and those paths are already one publish per read or batch.

Behavior change: verified overlays reach `snapshot()` one macrotask
after the relay frame instead of inside it. Hidden or occluded webviews
throttle `setTimeout`, so while the app is backgrounded, verified
changes (retractions, the switch to "cached" on disconnect) reach
`snapshot()` later. Only readers outside the rendered UI, such as
notifications, would see that delay.

## Related

- block#2159 makes each publish's namesake scan linear. This PR limits how
often publish runs; block#2159 limits what each run costs.
- block#2028 limits the members dialog's name reads to the visible page.

## Validation

- New test `coalesces a burst of verified changes into one directory
notification`: fails on main, passes here.
- Verified-profile and ProfilePanel tests now await the macrotask before
reading verified state.
- Full Vitest run locally: every file that failed under machine load
(load average ~30) passed on an isolated rerun. The pre-push hook timed
out on two unrelated UI tests (`App.profile`, `ProfileSettings`, 5 s
timeouts); both files pass when run alone (49/49).
- Not yet run in the desktop app.

## Open before merge

- The build that produced the trace above is not recorded, and block#2159 and
block#2209 are already in base, so this PR has not yet been shown to fix the
freeze. Needed: open the ~1,175-member members dialog with client
metrics on base and on head, and record total and max long-task time,
`fold` and publish counts, and whether "Relay read timed out" recurs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
…block#2234)

## Problem

A native NIP-FI socket renews its assertion every ~285 s: the lifetime
is capped at 300 s, minus a 15 s margin. `live.ts` reconnects quietly
and the connection stays reported `connected`, but `clearSocket()` ends
every sent sync with `disconnected: live connection lost`. Each
verified-profile (kind 30382) batch treated that as a failure:

- it cleared `checked` for its subjects;
- it republished the whole profile directory, and every name flipped
from checked to cached;
- the next `ensure` re-sent the batch, and its replay republished the
directory again at EOSE, even though nothing had changed.

With 1,024 verified subjects that is 8 batches. Each renewal therefore
caused 16 full republishes and 2,048 changed entries, and every checked
name dropped to cached for a moment.

## Change

- A batch that ends with `disconnected:` is re-sent at once with its
checked status kept, as a rebuilt batch already is. The decision waits
one microtask. An outage reports the connection down in the same task,
which stops every batch first, so an outage still clears status and
sends nothing extra.
- EOSE notifies only when it changed a head, a checked status or a
retraction. A replay that confirms what is shown is quiet.

## Evidence

Blox matrix: the real `createRelaySession` with the profile and verified
directories over a scripted live transport, 1,024 verified subjects.
Each renewal ends every sync as `clearSocket()` does, while states stay
`connected`. Base and variant runs were interleaved, 5 runs on each of 2
Linux and 2 macOS workstations. Values are medians of 10 per OS.

| Metric (5 renewals, ≈24 min of uptime) | Linux before | Linux after |
Linux Δ | macOS before | macOS after | macOS Δ |
|---|---|---|---|---|---|---|
| CPU (ms) | 327.5 | 22 | −93% | 194 | 27 | −86% |
| Profile notifications | 80 | 0 | −100% | 80 | 0 | −100% |
| Entries changed | 10,240 | 0 | −100% | 10,240 | 0 | −100% |
| Names checked after | 1,024 | 1,024 | 0 | 1,024 | 1,024 | 0 |
| Verified REQs / events | 40 / 5,120 | 40 / 5,120 | 0 | 40 / 5,120 | 40
/ 5,120 | 0 |

What you gain: every listener stops refolding on every renewal, about
12.6 times an hour on a NIP-FI relay, and verified names no longer
flicker from checked to cached. Wire traffic is unchanged.

The Blox runs predate the one-microtask deferral. Locally, the same
harness gives an identical renewal result (0 notifications, 1,024
checked). An outage scenario keeps `main`'s 40 REQs and clears status as
before.

- `verified-profile.test.ts`, new: "re-sends a batch a replaced socket
ended and keeps its check quiet while connected" fails on `main`. "does
not re-send a batch an outage ended" pins the outage path.
- `vitest run` on `src/features/relay`, `src/features/profiles` and
`src/features/messages`: 220 files and 5,002 tests pass, 4 skipped. `tsc
--noEmit` and biome are clean.

## Related

block#2216 makes verified publishes one per macrotask. That reduces how many
republishes a renewal causes, but each ended batch still drops its names
to cached until the re-sent batch answers. This PR keeps them checked
and keeps a confirming replay quiet, so the two compose. Same Blox
harness, stacked on block#2216, interleaved, 4 runs on each of 4 Linux and 4
macOS workstations. Values are medians of 16 per OS.

| 5 renewals @1,024 verified | Linux block#2216 | Linux block#2216 + this | Linux
Δ | macOS block#2216 | macOS block#2216 + this | macOS Δ |
|---|---|---|---|---|---|---|
| CPU (ms) | 74.5 | 21 | −72% | 64 | 27.5 | −57% |
| Profile notifications | 10 | 0 | −100% | 10 | 0 | −100% |
| Checked↔cached flips | 10,240 | 0 | −100% | 10,240 | 0 | −100% |
| Verified REQs | 40 | 40 | 0 | 40 | 40 | 0 |

Outage (5 disconnect/reconnect cycles) on block#2216 with and without this
change: 10 notifications and 40 REQs in both, CPU 78 vs 78 ms on Linux
and 132.5 vs 131.5 ms on macOS. The outage path is unchanged.

## Tradeoff

- The directory infers a renewal from the documented `disconnected:`
reason and from the connection still being up after the current task. It
does not get an explicit signal from `live.ts`, which keeps the change
to one file.
- During an outage, batches now end through `connected(false)` alone.
That is one profile notification per outage instead of one per batch
plus one. The end state is unchanged.
- A relay CLOSED whose reason starts with `disconnected:` is now
reported as `error: relay closed the sync`, so only local socket loss
takes the renewal path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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.

4 participants