Skip to content

chat: create_thread edge-write loop is non-atomic — partial failure leaves a committed-but-500'd chat #49

Closed

Description

@jjbaumert

Context: Follow-up from the code review of #48 (membership-edges rewrite of list_user_chats, merged as fe68b17). The two must-fix findings from that review were fixed before merge; this is the deferred should-fix, filed per the reviewer's recommendation to let the author choose the behavior policy explicitly rather than decide it silently.

Where: crates/storage/src/repo/thread_repo.rs, ThreadRepo::create_thread — the per-member edge-write loop appended after the METADATA put_item (for member in &thread.member_ids { self.put_chat_edge(...).await?; }).

Problem: The loop propagates the first put_chat_edge error with ? after the METADATA row (and any earlier members' edges) have already been committed. So a mid-loop failure (throttle, transient DynamoDB error) means:

  • The route (routes/chat.rs, POST /api/v1/chats) returns a 500 claiming the chat was not created.
  • But the THREAD#<id>/METADATA row and the first n−1 edges are committed — the chat exists and is partially visible.
  • A client that retries on the 500 creates a duplicate chat, while the original sits half-indexed (some members can see it, the failed-at member and later members cannot until a membership change or a backfill_chat_edges re-run repairs it).

Unlike add_chat_member/remove_chat_member (fixed in #48 to a single transact_write), create_thread can't use one transaction: DynamoDB caps TransactWriteItems at 100 and group membership is unbounded (no MAX_MEMBER cap exists in routes/chat.rs), so the METADATA row + N edges can exceed the limit.

The decision to make (why this is a ticket, not an auto-fix): two reasonable policies —

  1. Best-effort edges (recommended): log-and-continue on individual put_chat_edge failures instead of returning Err; the chat is created and the missing edges self-heal via backfill_chat_edges or the next membership change. Matches the self-heal philosophy already used in delete_thread and list_user_chats. Downside: a member could briefly not see a chat they're in.
  2. Fail-loud (current behavior): keep returning Err on any edge failure. Downside: the duplicate-chat-on-retry trap above, plus a committed-but-500'd chat.

Suggested approach: adopt policy 1 (best-effort with a warn! per failed edge carrying thread_id + user_id), OR — if fail-loud is preferred — make create_thread roll back the METADATA row on edge failure so the 500 is truthful (no orphan). Either way, add a test for the partial-failure path.

Severity: S4 — no current user-facing repro on the test stack (0 existing chats today), latent; matters as chat usage grows, especially group chats.

Non-blocking siblings from the same review (not filing separately unless wanted): (a) list_user_chats now does N sequential get_thread GetItems — bounded by the caller's own chat count, a candidate for a future batch_get_item helper; (b) chat membership add/remove write-paths don't emit SecurityAudit rows (pre-existing gap, unlike sharing.rs/documents.rs; per CLAUDE.md sharing-adjacent writes should).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions