gastownhall/beads · error

journal: snapshot %s for %s: %w

Error message

journal: snapshot %s for %s: %w

What it means

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.

Source

Thrown at internal/storage/issueops/journal.go:318

// Use it for every op except delete (which has no surviving row — use
// RecordDeleteInTx) and dependency ops (use RecordDepEventInTx). A no-op when
// journaling is disabled.
//
// actor is the acting identity that performed the mutation, as resolved for
// the audit-events table; "" when the mutation path genuinely has none
// (derived maintenance, actorless delete plumbing). It is an explicit
// parameter, not ambient context, so a new call site cannot compile without
// deciding attribution.
func RecordEventInTx(ctx context.Context, tx DBTX, op EventOp, issueID, actor string) error {
	if !journalEnabled(ctx, tx) {
		return nil
	}
	issue, err := getJournalIssueInTx(ctx, tx, issueID)
	if err != nil {
		// The row should exist for a non-delete op; a missing row means the
		// mutation and the journal disagree, so fail the transaction rather than
		// record a hole.
		return fmt.Errorf("journal: snapshot %s for %s: %w", op, issueID, err)
	}
	return insertEventRow(ctx, tx, op, issueID, issue, nil, nil, actor)
}

// RecordDeleteInTx records a delete for issueID with a null issue payload (the
// row no longer exists). A no-op when journaling is disabled. actor as on
// RecordEventInTx.
func RecordDeleteInTx(ctx context.Context, tx DBTX, issueID, actor string) error {
	if !journalEnabled(ctx, tx) {
		return nil
	}
	return insertEventRow(ctx, tx, EventDelete, issueID, nil, nil, nil, actor)
}

// journalableDeletesInTx narrows ids to the ones that actually exist in table,
// so a bulk delete records only rows it really removes. It is a no-op (nil,
// nil) when journaling is disabled, keeping the extra read off the ordinary
// local delete path. Callers MUST invoke it before issuing their DELETE.

View on GitHub (pinned to 71377f2769)

Solutions

  1. Verify the issueID exists before the mutation (or handle delete via RecordDeleteInTx)
  2. Check for concurrent deletes racing this mutation; serialize ordering
  3. Inspect the wrapped getJournalIssueInTx cause to distinguish missing-row vs SQL failure
  4. If the source may legitimately be gone, use RecordDepEventInTx-style null-snapshot handling instead

Example fix

// before
if err := RecordEventInTx(ctx, tx, EventClose, id, actor); err != nil { ... }
// after
var exists int
_ = tx.QueryRow("SELECT 1 FROM issues WHERE id = ?", id).Scan(&exists)
if exists == 0 { return storage.ErrNotFound } // or use RecordDeleteInTx
Defensive patterns

Strategy: try-catch

Validate before calling

var exists int
err := db.QueryRow("SELECT 1 FROM issues WHERE id = ?", issueID).Scan(&exists)
if err == sql.ErrNoRows { /* use RecordDeleteInTx instead, or abort */ }

Type guard

null

Try / catch

if err := issueops.RecordEventInTx(ctx, tx, EventUpdate, id, actor); err != nil {
  if errors.Is(err, storage.ErrNotFound) { return issueops.RecordDeleteInTx(ctx, tx, id, actor) }
  return fmt.Errorf("journal event failed: %w", err)
}

Prevention

When it happens

Trigger: 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.

Common situations: Double-close or claim of an already-deleted issue; cascade delete removing the row mid-transaction; wrong issue ID passed in; database connectivity problems.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/68b5abc130a92828. Report an issue: GitHub.