Skip to content

Order messages by sub-second send time - #249

Merged
baxen merged 3 commits into
mainfrom
message-ms-ordering
Sep 25, 2026
Merged

baxen merged 3 commits into
mainfrom
message-ms-ordering

Conversation

@baxen

@baxen baxen commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Messages sent in the same second (e.g. rapid-fire "yeah / nice / love it") could render out of order, because Nostr created_at has whole-second resolution and ties fell back to event id. This adds a signed sub-second send time and a single comparator for all message views.

Changes

  • New src/features/relay/message-order.ts
    • eventMs: reads the event's ["ms", "<0–999>"] subsecond tag and reconstructs created_at * 1000 + offset. Only canonical decimal offsets are honored. Otherwise it falls back to created_at * 1000.
    • compareMessages: effective ms ascending, then id ascending. This is now the only sort for rendered message rows.
    • MessageClock: one clock per relay session, with per-channel observed and local-send watermarks. Other authors may lead allocation by at most 5s; the local send chain stays increasing up to a 60s lead.
  • outbox.ts: outbox.send adds the ms % 1000 tag and sets created_at = floor(ms/1000) before the id is computed. This applies to channel-row kinds (9 / 40002 / 40008 / 40099) with an h tag. The optimistic row and the signed event are identical, so rows don't move when the relay acknowledges them.
  • fold.ts, message-projection.ts, threads.ts, MediaReviewViewer.tsx: use compareMessages. This removes the channel view's descending-id tiebreak, so the channel and thread views now agree. Each also records observed ms for send allocation.
  • contracts.ts / row-identity.ts: optional createdAtMs on ChannelMessage.
  • Relay: no changes. The tag is an ordinary signed tag, and the relay's created_at paging is untouched. Thread paging cursors still use (created_at, id) to match the relay.

Known limits

  • Clients without the tag sort at the start of their second.
  • Watermarks are owned by each relay session. If no view is open for a channel, a send there only builds on this device's last send and the clock.
  • Pre-release epoch-millisecond tags fall back to whole seconds; new sends use only 1,000 possible tag values. This reduces cardinality, not all GIN write/storage cost.

Testing

  • message-order.test.ts:
    • comparator and tag validation
    • channel and thread views produce the same order
    • the 5s cap
    • three sends in one second keep their send order when the relay copies replace the optimistic rows
  • At the time of commit, all passing:
    • full Vitest: 2836 tests
    • node --test tests/integration/*.test.mjs: 127
    • pnpm typecheck, pnpm lint, pnpm check
  • Not yet tried in the running app or against a live relay.

Subsecond encoding follow-up and rebase

  • Head f22289ec4afbeb89bc2965128af64e3b60a1f7ab, rebased onto main 64be4c2a with original authors and DCO trailers preserved.
  • New regression coverage checks all 1,000 offsets, malformed values, and 999→0 second rollover through optimistic rows, signing and relay replacement. Existing nested-reply coverage from main preserved.
  • Full Vitest on this head: 4,092 passed / 370 files. Pre-push TypeScript, related tests and design checks passed.
  • Earlier validation against main e52ec14f reproduced two baseline failures. Main's agent-delete test fix was incorporated; the feedback-broker failure did not recur in the final full run (not claiming it is fixed).
  • No browser scenarios added/removed. No live-app acceptance, native build, or browser run performed for this follow-up; hosted CI and human testing remain separate gates.

@baxen
baxen marked this pull request as ready for review September 25, 2026 01:02
@baxen
baxen requested review from a team, comp615 and wesbillman as code owners September 25, 2026 01:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56a58c1aef

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/relay/message-order.ts Outdated
Comment on lines +27 to +28
const latest = new Map<string, number>();
let lastSent = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope ordering watermarks to the relay session

Move these watermarks under a relay-session owner or include the community origin and viewer in their keys. The app retains multiple community sessions, and docs/communities.md:50-59 explicitly states that channel IDs alone are not sufficient keys; with this module-global map and lastSent, observing channel c in community A changes the signed ms/created_at allocated for channel c in community B, potentially pushing B's messages several seconds into the future or into the clamp path.

AGENTS.md reference: AGENTS.md:L39-L42

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Agreed. In bfe8e61 the module globals are replaced by a MessageClock that createRelaySession creates once and shares with its outbox, channel windows and thread views. Channel keys therefore never mix across community sessions. The regression test runs two sessions with the same channel id, and it fails if the session goes back to a shared clock. This also fixes an intermittent mentions-live failure caused by watermarks leaking between tests.

Comment thread src/features/relay/message-order.ts Outdated
/** Allocate a send time after everything known in the channel, bounded by the local clock. */
export function nextMessageMs(channelId: string, now = Date.now()) {
const after = Math.max(latest.get(channelId) ?? 0, lastSent) + 1;
const ms = after - now > MAX_LEAD_MS ? now : Math.max(now, after);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unique send times after clamping

When a same-channel event is more than five seconds ahead—because of a skewed peer clock or a local clock rollback—this branch returns now, but observeMessageMs cannot replace the still-future latest value. Multiple sends in the same millisecond therefore all clamp to the identical ms, leaving their order to random event IDs and recreating the rapid-fire reordering this change is meant to prevent; maintain a separate bounded local watermark after entering the clamp path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Agreed, same root cause as Wes's comment. Fixed in bfe8e61 with a separate local send watermark, so clamped sends in one millisecond stay strictly increasing.

@chatgpt-codex-connector

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-09-25T01:06:51.470726Z 56a58c1 Draft marked ready
ℹ️ 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.

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at 56a58c1aef2660ed8833cb9696f6689be1a33a28: one allocator correctness issue, detailed inline. Merge criteria: preserve local send order at/over the lead cap and cover the boundary through rendered-row ordering and optimistic-to-relay replacement.

Validation: source review across signing, channel/thread/media rendering, persistence and relay cursors; exact-source Node probes reproduce the allocator failure. No local suite, running-app or live-relay validation. Hosted checks currently show only Semgrep OSS, zizmor and DCO passes; the branch conflicts with main, so conflict resolution and fresh CI remain separate merge gates.

Non-blocking: unread/activity “latest” selectors still use seconds/lowest-ID order (unread.ts:355–358,438–461); their previews can disagree with the new timeline. Untagged clients still sort at the start of their second, as documented. Neither expands the required fix here.

Comment thread src/features/relay/message-order.ts Outdated
Comment on lines +36 to +40
export function nextMessageMs(channelId: string, now = Date.now()) {
const after = Math.max(latest.get(channelId) ?? 0, lastSent) + 1;
const ms = after - now > MAX_LEAD_MS ? now : Math.max(now, after);
lastSent = ms;
observeMessageMs(channelId, ms);

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.

[P2] Preserve local send order when the five-second budget runs out

The fallback can assign a later send an earlier signed timestamp. With observeMessageMs(channel, now + 4999), two calls to nextMessageMs(channel, now) return now + 5000 and then now. I ran the exact-head functions: compareMessages renders those two rows as 2, 1. The existing cap test asserts those allocations but never checks their combined order. Since the outbox uses this value for both created_at and the signed ms tag, relay replacement cannot repair the inversion.

The same fallback also ignores the local increment while an observed timestamp remains beyond the cap: observing now + 60000 makes a same-clock burst return [now, now, now], restoring arbitrary event-ID ordering. Choose a bounded policy that does not rewind or discard local send ordering, and add near-cap/over-cap burst coverage asserting rendered order before and after relay replacement.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in bfe8e61. The allocator now keeps two watermarks per channel:

  • Other authors' evidence is capped at now+5s.
  • This device's own sends always get strictly increasing times, up to a 60s lead. Past that (a clock rollback), the chain restarts at the clock.

So a near-cap burst gives now+5000, +5001, +5002, and so does an over-cap burst (evidence at now+60s). Neither rewinds nor ties. New tests assert rendered row order for a near-cap burst through MessageProjection, both for the optimistic rows and after replacement with the signed relay events, plus a rollback case. Reverting to the old rule fails all three.

@baxen
baxen force-pushed the message-ms-ordering branch from 56a58c1 to bfe8e61 Compare September 25, 2026 01:43
@baxen

baxen commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Rebased onto main (the only conflict was the new overlay-depth constants next to the sort in message-projection.ts). Review fixes are in bfe8e61, with replies inline.

Local checks on bfe8e61, all passing:

  • vitest: 3529/3529, two runs
  • integration: 133/133
  • pnpm check and pnpm lint

On the rebased code before the fixes, Playwright had 4/634 failures. Rerunning those two specs:

  • The webkit image-scroll anchors test fails the same way on origin/main.
  • The emoji picker tests passed when rerun on both main and this branch.

This PR doesn't change the non-blocking unread/activity "latest" selectors, which still sort by second. That's a follow-up.

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Scoped re-review clear at bfe8e6131a0934ebaf8709033653205938d9bd25. The prior near-cap rewind/over-cap tie blocker is repaired: separate local-send watermarks preserve live-session burst order, and signed event IDs keep that order through optimistic-to-relay replacement. The per-session clock is consistently shared by the outbox and channel/thread owners. This is a COMMENTED review, not approval or merge clearance.

Remaining gates and limits:

  • The PR has merge conflicts. Existing CI 36083284018, attempt 3, passes JavaScript, Rust, all Chromium/WebKit shards, measurements and CI required against the historical merge with b276b867d91646522c6ed354cfbedffb1e101882, not current main. Resolve conflicts and validate the resulting head. Windows was skipped; I initiated no rerun.
  • Non-blocking coverage improvement: the clock tests seed peer evidence directly, while the session-isolation test sends into empty channels. Add an integration case that loads peer evidence through the real channel/thread owner, then sends, to protect the shared-clock wiring. These are source observations, not executed mutation tests.
  • This clearance is for the agreed live-session burst contract, not durable monotonicity across reconnect/restart or clock rollback. Replacing a session resets its local-send watermark; restored outbox events do not directly seed it. Source review only: no PR code/tests, running-app or live-relay/restart validation. Existing seconds/ID paging and mixed-client limitations remain unchanged.

The previous changes-requested review remains in GitHub; this follow-up does not dismiss it.

baxen and others added 3 commits September 25, 2026 13:21
Add an ms tag to every channel/thread message sent through the outbox and
order the channel timeline, threads, history, live merge and optimistic rows
with one comparator: effective ms ascending, then event id ascending.

Signed-off-by: Bradley Axen <baxen@squareup.com>
- Replace the module-global watermarks with a MessageClock owned by each
  relay session and shared with its outbox, channel windows and thread
  views, so channel ids never mix across communities or viewers.
- Other authors' timestamps may still lead the clock by at most 5s, but
  this device's own sends in a channel now stay strictly increasing (up
  to a 60s lead), so a near- or over-cap burst never rewinds or ties.
- Cover near-cap and over-cap bursts through optimistic and relay rows,
  clock rollback, and per-session isolation via createRelaySession.

Signed-off-by: Honey <44b804aa12cc643328829fa31c020063ef3636c3a73c38ca49641c7626c11bb1@buzz.block.builderlab.xyz>
Signed-off-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
@baxen
baxen force-pushed the message-ms-ordering branch from 372d48c to f22289e Compare September 25, 2026 20:24

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Scoped re-review clear: head f22289ec4afbeb89bc2965128af64e3b60a1f7ab, base 64be4c2adf21e9920d4a7666354ceab1d784dd44. No new blocker found in the 0–999 offset encoding or rebase; the prior live-session burst repair remains intact. COMMENTED, not approval or whole-feature merge clearance.

Two non-blocking follow-ups are detailed inline: split-second history paging and thread-root clock seeding. Both predate this follow-up. Paging is a regression versus main, but I am retaining the prior scoped deferral, not claiming history ordering is fully solved. Durable restart/reconnect ordering and mixed-client chronology also remain outside this clearance.

Validation: source review and focused production-owner probes. A real local HTTP broker with a scripted relay socket preserved signed identity and order across a second rollover. Session probes reproduced both follow-ups. No browser/DOM or live-relay acceptance, broad local suite rerun, or tracked edits. Full diff has no added screenshots/generated artifacts.

Remaining evidence: latest hosted snapshot has JavaScript, Rust/tool integration, measurements, Chromium shard 2 and security/DCO checks passing; five browser shards are running, Windows skipped. Complete applicable CI and running-app/human acceptance or obtain an explicit waiver. This review neither approves nor dismisses existing reviews.

return rows.sort(
(a, b) => a.createdAt - b.createdAt || b.id.localeCompare(a.id),
);
return rows.sort(compareMessages);

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.

Deferred, non-blocking: split-second pages do not preserve prepend-only rendering.

The channel relay still pages by (created_at desc, id asc), while this comparator is (effective ms asc, id asc). In a production-session/scripted-relay probe with 25 untagged rows in one second, the 20-row head excludes the final five rows in the new rendered order; loadOlder() appends them at indices 20–24. Splitting an older second also inserts rows mid-list. “Final” here means comparator order, not unknowable legacy send chronology. Base prepended those rows.

This predates the offset/rebase follow-up and remains within the prior scoped paging deferral. The earlier review did not spell out this consequence; that omission is mine. ChannelTimeline’s prepend detection assumes first/last-edge insertion; scrolling impact is source-inferred, not DOM-reproduced. A follow-up should reconcile page boundaries with rendered order within existing budgets and test head completeness/anchoring. Keep relay cursor ordering unchanged; changing the client cursor comparator alone is incompatible.

)
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
.sort(compareMessages);
for (const row of nextReplies)

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.

Optional: seed the shared clock from the displayed root too.

This loop only observes replies. With thread-only/exact entry and no channel window, a displayed root at S.900 followed by a first local reply at S.100 allocates S.100; an inline session-channel fold renders the reply before its root. I reproduced the same outcome with a root 3.3 seconds ahead. This predates the encoding/rebase follow-up and is not reopening the agreed burst fix.

Observe readable rather than just nextReplies, and cover thread-only entry through the real session owner. A scratch-only version of that change makes both probe cases allocate root+1; the review checkout was not modified. The normal thread panel keeps its root separate, so the demonstrated inversion concerns inline rendering.

@baxen
baxen merged commit 3693c12 into main Sep 25, 2026
14 checks passed
@baxen
baxen deleted the message-ms-ordering branch September 25, 2026 20:56
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