gastownhall/beads · error

wake expired defer %s: %w

Error message

wake expired defer %s: %w

What it means

Thrown when the UPDATE that transitions an expired deferred issue to 'open' fails to execute. The UPDATE is guarded (status='deferred' AND defer_until expired), so failures are driver/connection/SQL-level, not concurrency. The already-woken ids are returned alongside the error so callers can report partial progress.

Source

Thrown at internal/storage/issueops/wake_defers.go:122

	if len(expired) == 0 {
		return nil, nil
	}

	var woken []string
	now := time.Now().UTC()
	for _, id := range expired {
		// row_lock is rewritten so a concurrent claim/update conflicts at
		// commit time instead of cell-merging with this write — the same
		// invariant the lease scheme depends on.
		//nolint:gosec // G201: table is a hardcoded constant from the caller above.
		res, err := tx.ExecContext(ctx, fmt.Sprintf(`
			UPDATE %s
			SET status = 'open', defer_until = NULL, updated_at = ?, row_lock = ?
			WHERE id = ? AND status = 'deferred' AND defer_until IS NOT NULL
			  AND defer_until <= UTC_TIMESTAMP()
		`, table), now, freshRowLock(), id)
		if err != nil {
			return woken, fmt.Errorf("wake expired defer %s: %w", id, err)
		}
		n, err := res.RowsAffected()
		if err != nil {
			return woken, fmt.Errorf("wake expired defer %s rows affected: %w", id, err)
		}
		if n == 0 {
			continue // rescued concurrently — leave it be
		}
		if err := RecordFullEventInTable(ctx, tx, eventsTable, id, types.EventStatusChanged,
			DeferWakeActor, string(types.StatusDeferred), string(types.StatusOpen)); err != nil {
			return woken, fmt.Errorf("record wake event for %s: %w", id, err)
		}
		// A wake is a status change, so it journals as an update. Emitted past
		// the rows-affected re-check, so a concurrently-rescued bead records
		// nothing.
		if err := RecordEventInTx(ctx, tx, EventUpdate, id, DeferWakeActor); err != nil {
			return woken, err
		}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Inspect the wrapped driver error for unknown-column vs lock-timeout vs connection failure.
  2. Run schema migration to add defer_until/row_lock/updated_at if the table predates them.
  3. Increase innodb_lock_wait_timeout or resolve competing transactions holding the row.
  4. Retry — the guarded WHERE clause makes re-running safe; already-woken ids are skipped.

Example fix

// before: old table without row_lock
ALTER TABLE issues ADD COLUMN defer_until DATETIME NULL;
// after
ALTER TABLE issues ADD COLUMN defer_until DATETIME NULL, ADD COLUMN row_lock VARCHAR(64) NULL;
Defensive patterns

Strategy: try-catch

Validate before calling

for _, col := range []string{"defer_until", "row_lock", "updated_at"} {
    var c string
    err := db.QueryRow("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME='issues' AND COLUMN_NAME=?", col).Scan(&c)
    if err != nil { return fmt.Errorf("missing column %s: run migration", col) }
}

Try / catch

if err != nil {
    var myErr *mysql.MySQLError
    if errors.As(err, &myErr) && myErr.Number == 1205 { // lock wait timeout
        return fmt.Errorf("row locked by another transaction; retry later: %w", err)
    }
    return fmt.Errorf("wake failed for %s (partial: %v woken): %w", id, woken, err)
}

Prevention

When it happens

Trigger: Calling WakeExpiredDefersInTx when the UPDATE statement fails: broken transaction connection, missing columns (updated_at, row_lock, defer_until), SQL syntax incompatibility, or lock wait timeout on the row.

Common situations: Schema drift after a version upgrade (no row_lock column); innodb_lock_wait_timeout exceeded while another transaction holds the row; connection dropped inside the surrounding transaction.

Related errors


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