Skip to content

Write the v2 read cursor natively on v2-primary /api/mark-read - #228

Open
MaxGhenis wants to merge 1 commit into
claude/interesting-payne-d53972from
claude/v2-native-mark-read
Open

MaxGhenis wants to merge 1 commit into
claude/interesting-payne-d53972from
claude/v2-native-mark-read

Conversation

@MaxGhenis

Copy link
Copy Markdown
Owner

Stacked on #217, which is open, so the base is #217's branch. I'll retarget this to main and rebase once #217 merges.

What was missing

#217 stopped a v2-primary daemon from running the legacy mirror on POST /api/mark-read. The mirror can't resolve the v2 conversation ids a v2-primary UI sends. For a legacy id the v2 store lacks, it would add a legacy-keyed conversation row to the primary store. The cost was that a v2-primary daemon wrote no v2 read cursor at all. Nothing consumes v2 read cursors yet: v2read maps UnreadCount: 0, and the web UI calls mark-read only when UnreadCount > 0. So no visible behavior regressed, and today only API callers reach this path on v2-primary. The S5b/S8 unread derivation will need the cursors.

Change

On v2-primary, /api/mark-read now calls v2wire.MarkReadV2, which writes the cursor natively:

Step What it does
Resolve the conversation v2read.ResolveConversation, the alias rule v2 reads already used, now exported. The v2 id resolves to itself. Any other value is tried as remote_conversation_id across accounts, normalized per platform (so signal: +1555… matches), and the most recently active match wins. The read path (Source.resolveConversationID) now calls the same function, so a stored legacy id writes to the thread it reads. An id that matches nothing writes nothing and returns ErrNotFound.
Pick the device GetLocalInstallationDevice(account): the device ingest advances receipt cursors for, whatever its id (9bcc1343… for Google on a migrated store). An account with no local device gets one through EnsureLocalInstallationDevice, under the migration's derived id. That id is now one helper, v2keys.LocalInstallationDeviceID, used by both the migration and this path. Nothing here assumes local-primary:<account>.
Write UpsertReadCursor with LastReadMessageID: nil and LastReadAtMS = UpdatedAtMS = now. The upsert is monotone in read time.
Failure Best effort, as before: logs Failed to write v2 read cursor with conv_id at warn, and the response stays 200. No read receipt goes to the phone.

Legacy-primary daemons still run the mirror, unchanged. Both writes now run under context.WithoutCancel(r.Context()), so a client that hangs up mid-request doesn't drop the v2 write after the legacy write has landed.

Why LastReadMessageID stays nil

I considered pointing the cursor at the conversation's newest message. Every other cursor writer records a position: migration, ingest receipts, and the outbox read path. A NULL written at a later time also replaces a migrated cursor's position. But naming the newest message is not safe today:

  • read_cursors has FOREIGN KEY (conversation_id, last_read_message_id) REFERENCES messages(conversation_id, message_id), with no ON DELETE action, and foreign_keys(ON) is set on every connection.
  • OutboxRepository.repointLocalMessage deletes a send's echo duplicate on reconcile. The echo is exactly the row most likely to be the newest message when the user opens the thread.
  • Executed: I seeded the TestOutboxReconcileConfirmMergesCollisionAndPreservesLocalAnchor scenario with a read cursor on the echo duplicate, then ran ReconcileConfirm. It failed with delete echo duplicate "message-echo-duplicate": … FOREIGN KEY constraint failed (787), so the send stays unreconciled.
  • Id-space repair (repair_idspace.go) already skips moving or deleting any message a cursor references (MessageHasReadCursor), so a moving pointer at the newest message would also pin rows that repair needs to fix.

A NULL position dated at the request says what mark-read means ("everything up to now is read", which is the legacy unread_count = 0). It matches the mirror and the ReadCursor doc ("may be nil for … cursors that only approximate a position"). Once the FK or the deletes handle cursors, S5b/S8 can switch this to a position.

Invariants (property-tested)

  • Monotone: MarkReadV2 never lowers a cursor's last_read_at_ms. A call dated before the stored cursor leaves it byte-identical, including a positioned cursor from another writer.
  • Exact write: a call that is not older than the stored cursor leaves exactly {LastReadMessageID: nil, LastReadAtMS: at, UpdatedAtMS: at} on (local installation device, resolved conversation).
  • Alias equivalence: a thread's v2 id and its legacy alias write the same cursor. ResolveConversation agrees with a brute-force reading of the alias rule, and with the conversation the v2 read path serves for the same key (differential).
  • Containment: no account, conversation or other cursor changes. The device set changes only by adding LocalInstallationDeviceID(account) to an account that had no local installation device.
  • Order independence: the same calls in any order end at the same cursors.
  • Unknown ids: they write nothing and return ErrNotFound. The HTTP response stays 200 whatever happens to the v2 write.

Tests

  • internal/web/markread_v2primary_test.go: the store is built with the real migration.Transform, and the fixture pins derived devices and migrated cursors that name a message.
    • TestMarkReadV2PrimaryWritesNativeCursorOnMigratedStore: Google, WhatsApp and Signal threads, each by v2 id and by legacy alias. The cursor lands on the derived device and no other row changes.
    • TestMarkReadV2PrimaryUnknownConversationLogsAndWritesNothing: a legacy id the v2 store lacks returns 200, logs the warning, and writes nothing.
    • TestMarkReadV2PrimaryWithoutV2StoreStays200.
    • TestMarkReadV2PrimaryCursorMonotoneProperty (testing/quick, 15 migrated stores × up to 8 requests): random threads get positioned cursors from another writer, dated up to a day before or after now, and then random v2-id or alias requests run. Cursors dated after a request survive it, others become {nil, t, t} with t inside the request window, and nothing else changes.
  • internal/v2wire/read_native_test.go: migrated-store cases with the v2 id, the legacy alias and a padded alias. Unknown, empty and post-cutover legacy ids write nothing. Invalid input (nil or canceled context, nil store, non-positive time) writes nothing. Device creation covers an account with no device and one whose only local device is non-current. TestMarkReadV2CursorProperties runs 60 random account, device and cursor shapes, each with up to 12 calls inside a 2-second window, so equal, older and newer calls are common. It checks a model after every call and replays the calls in reverse.
  • internal/v2read/alias_resolve_test.go: example cases, plus TestResolveConversationMatchesReferenceAndReadPath (40 random stores whose remote ids collide across accounts and whose recency ties). It compares a reference implementation with Source.GetConversation.
  • internal/v2keys: LocalInstallationDeviceID against the migration's recorded device goldens.

Mutation check: five mutants, each killed by both the v2wire and the web suites:

  1. No alias (exact GetConversation).
  2. Always upsert local-primary:<account>.
  3. Point the cursor at the newest message.
  4. Drop the monotone WHERE in UpsertReadCursor.
  5. Skip the v2-primary write.

Runbook

docs/agent-runbook.md, in the cutover section: the mirror bullet now points to the native write instead of saying a v2-primary daemon writes nothing. A new bullet describes how the id is resolved and which device the cursor lands on, why the message is NULL, the log line, and a read-only query for the latest cursors.

Verification

  • GOWORK=off go test -race -count=1 ./internal/v2read/ ./internal/v2wire/ ./internal/v2keys/ ./internal/migration/ ./internal/web/ passes.
  • go build ./... and go vet (touched packages and ./cmd/) are clean.
  • I didn't run ./... locally, to spare the shared build cache while host disk is low; CI runs it.

Not in this PR

🤖 Generated with Claude Code

#217 stopped a v2-primary daemon from running the legacy mirror on
/api/mark-read, so it wrote no v2 read cursor at all. It now writes one
natively (v2wire.MarkReadV2):

- The conversation id resolves the way v2 reads do: the v2 id, or a
  legacy-form id through remote_conversation_id. The alias rule moves
  into v2read.ResolveConversation, which the read path now calls too.
  An id that resolves to nothing writes nothing.
- The cursor goes on the account's local installation device
  (GetLocalInstallationDevice), whatever its id. An account with none
  gets one under the migration's derived id, now shared as
  v2keys.LocalInstallationDeviceID.
- LastReadMessageID stays nil, as in the mirror. read_cursors references
  messages with no ON DELETE action, so a cursor on the newest message
  pins it, and the outbox's echo-duplicate delete on reconcile then
  fails with FOREIGN KEY constraint failed.
- UpsertReadCursor keeps it monotone in read time. The write is best
  effort: a failure is logged and the response stays 200.

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

This branch has not been deployed

No deployments
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