{"record":{"id":"68b5abc130a92828","repo":"gastownhall/beads","slug":"journal-snapshot-s-for-s-w","errorCode":null,"errorMessage":"journal: snapshot %s for %s: %w","messagePattern":"journal: snapshot (.+?) for (.+?): %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/journal.go","lineNumber":318,"sourceCode":"// Use it for every op except delete (which has no surviving row — use\n// RecordDeleteInTx) and dependency ops (use RecordDepEventInTx). A no-op when\n// journaling is disabled.\n//\n// actor is the acting identity that performed the mutation, as resolved for\n// the audit-events table; \"\" when the mutation path genuinely has none\n// (derived maintenance, actorless delete plumbing). It is an explicit\n// parameter, not ambient context, so a new call site cannot compile without\n// deciding attribution.\nfunc RecordEventInTx(ctx context.Context, tx DBTX, op EventOp, issueID, actor string) error {\n\tif !journalEnabled(ctx, tx) {\n\t\treturn nil\n\t}\n\tissue, err := getJournalIssueInTx(ctx, tx, issueID)\n\tif err != nil {\n\t\t// The row should exist for a non-delete op; a missing row means the\n\t\t// mutation and the journal disagree, so fail the transaction rather than\n\t\t// record a hole.\n\t\treturn fmt.Errorf(\"journal: snapshot %s for %s: %w\", op, issueID, err)\n\t}\n\treturn insertEventRow(ctx, tx, op, issueID, issue, nil, nil, actor)\n}\n\n// RecordDeleteInTx records a delete for issueID with a null issue payload (the\n// row no longer exists). A no-op when journaling is disabled. actor as on\n// RecordEventInTx.\nfunc RecordDeleteInTx(ctx context.Context, tx DBTX, issueID, actor string) error {\n\tif !journalEnabled(ctx, tx) {\n\t\treturn nil\n\t}\n\treturn insertEventRow(ctx, tx, EventDelete, issueID, nil, nil, nil, actor)\n}\n\n// journalableDeletesInTx narrows ids to the ones that actually exist in table,\n// so a bulk delete records only rows it really removes. It is a no-op (nil,\n// nil) when journaling is disabled, keeping the extra read off the ordinary\n// local delete path. Callers MUST invoke it before issuing their DELETE.","sourceCodeStart":300,"sourceCodeEnd":336,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/journal.go#L300-L336","documentation":"RecordEventInTx snapshots the issue row before inserting a journal event. If snapshotting fails, the mutation and the journal disagree (row missing for a non-delete op), so the transaction is failed rather than recording a journal hole. Only ErrNotFound from getJournalIssueInTx surfaces as missing-row; other errors are wrapped here too.","triggerScenarios":"Calling ClaimIssueInTx / closeIssueInTx / CreateIssueInTxWithResult / AddLabelInTx / recordRenameInJournal for an issueID whose row does not exist at journal time (e.g. deleted concurrently), or the snapshot SELECT fails on a SQL/connection error.","commonSituations":"Double-close or claim of an already-deleted issue; cascade delete removing the row mid-transaction; wrong issue ID passed in; database connectivity problems.","solutions":["Verify the issueID exists before the mutation (or handle delete via RecordDeleteInTx)","Check for concurrent deletes racing this mutation; serialize ordering","Inspect the wrapped getJournalIssueInTx cause to distinguish missing-row vs SQL failure","If the source may legitimately be gone, use RecordDepEventInTx-style null-snapshot handling instead"],"exampleFix":"// before\nif err := RecordEventInTx(ctx, tx, EventClose, id, actor); err != nil { ... }\n// after\nvar exists int\n_ = tx.QueryRow(\"SELECT 1 FROM issues WHERE id = ?\", id).Scan(&exists)\nif exists == 0 { return storage.ErrNotFound } // or use RecordDeleteInTx","handlingStrategy":"try-catch","validationCode":"var exists int\nerr := db.QueryRow(\"SELECT 1 FROM issues WHERE id = ?\", issueID).Scan(&exists)\nif err == sql.ErrNoRows { /* use RecordDeleteInTx instead, or abort */ }","typeGuard":"null","tryCatchPattern":"if err := issueops.RecordEventInTx(ctx, tx, EventUpdate, id, actor); err != nil {\n  if errors.Is(err, storage.ErrNotFound) { return issueops.RecordDeleteInTx(ctx, tx, id, actor) }\n  return fmt.Errorf(\"journal event failed: %w\", err)\n}","preventionTips":["Verify the issue exists before claim/close/rename mutations","Use RecordDeleteInTx for deleted rows, not RecordEventInTx","Avoid concurrent delete + update of the same issue id"],"tags":["journal","snapshot","transaction","missing-row"],"backgroundTag":"journal-snapshot-missing","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}