gastownhall/beads · error

cannot demote issue %s with retained snapshots

Error message

cannot demote issue %s with retained snapshots

What it means

rejectPersistenceDemotion blocks moving an issue to a lower-durability plane (e.g. durable issues -> ephemeral wisp) when retained rows exist in issue_snapshots or compaction_snapshots for that issue. Demoting would orphan or destroy history that the snapshot tables are supposed to preserve, so the move is refused unconditionally. It is a hard guard, not a transient error.

Source

Thrown at internal/storage/issueops/persistence.go:148

	}
	return result, nil
}

func persistenceIssueTable(wisp bool) string {
	if wisp {
		return "wisps"
	}
	return "issues"
}

func rejectPersistenceDemotion(ctx context.Context, tx DBTX, id string) error {
	for _, table := range []string{"issue_snapshots", "compaction_snapshots"} {
		var count int
		if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE issue_id = ?`, table), id).Scan(&count); err != nil {
			return fmt.Errorf("check retained snapshots in %s: %w", table, err)
		}
		if count > 0 {
			return fmt.Errorf("cannot demote issue %s with retained snapshots", id)
		}
	}
	return nil
}

func copyPersistenceAuxiliary(ctx context.Context, tx DBTX, id string, sourceWisp, targetWisp bool) (map[string]bool, error) {
	changed := map[string]bool{}
	for i, from := range persistenceAuxTables(sourceWisp) {
		to := persistenceAuxTables(targetWisp)[i]
		columns, key, err := persistenceColumns(from)
		if err != nil {
			return nil, err
		}
		copied, err := tx.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s (%s) SELECT %s FROM %s WHERE %s = ?`, to, columns, columns, from, key), id)
		if err != nil {
			return nil, fmt.Errorf("copy %s: %w", from, err)
		}
		if rows, err := copied.RowsAffected(); err == nil && rows > 0 {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Expire/purge the issue's snapshots (per your retention policy) before attempting the demotion
  2. Promote or keep the issue on its current, more durable plane instead of demoting
  3. If demotion is genuinely required, snapshot-retention must be resolved first: delete or archive the snapshot rows in the same transaction, then retry
  4. Split the batch: demote only issues with zero retained snapshots (check both tables first)

Example fix

// before
err := issueops.MoveIssuePersistenceInTx(ctx, tx, id, true) // demote to wisp
// after
for _, tbl := range []string{"issue_snapshots", "compaction_snapshots"} {
	var n int
	tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl+" WHERE issue_id = ?", id).Scan(&n)
	if n > 0 {
		return fmt.Errorf("issue %s has %d rows in %s; expire snapshots before demotion", id, n, tbl)
	}
}
err := issueops.MoveIssuePersistenceInTx(ctx, tx, id, true)
Defensive patterns

Strategy: validation

Validate before calling

for _, tbl := range []string{"issue_snapshots", "compaction_snapshots"} {
	var n int
	if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl+" WHERE issue_id = ?", id).Scan(&n); err != nil {
		return err
	}
	if n > 0 {
		return fmt.Errorf("issue %s retains %d snapshot(s) in %s; resolve before demotion", id, n, tbl)
	}
}

Type guard

func canDemote(ctx context.Context, tx issueops.DBTX, id string) (bool, error) {
	for _, tbl := range []string{"issue_snapshots", "compaction_snapshots"} {
		var n int
		if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl+" WHERE issue_id = ?", id).Scan(&n); err != nil {
			return false, err
		}
		if n > 0 { return false, nil }
	}
	return true, nil
}

Prevention

When it happens

Trigger: Calling MoveIssuePersistenceInTx to demote an issue whose id has COUNT(*) > 0 in either issue_snapshots or compaction_snapshots within the same transaction.

Common situations: Compacting/cleaning up old issues without first expiring their compaction snapshots; bulk demotion scripts over issues that were previously compacted; migration tooling moving issues to the wisp plane while history-retention policy still holds their snapshots.

Related errors


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