gastownhall/beads · error

db: ChildCounterSQLRepository.NextChildID: upsert counter fo

Error message

db: ChildCounterSQLRepository.NextChildID: upsert counter for %s: %w

What it means

NextChildID persists the computed next child number via an INSERT ... ON DUPLICATE KEY UPDATE into `child_counters` (or `wisp_child_counters`). This error wraps any ExecContext failure from that upsert. It means the child ID was computed but could not be recorded, so callers must not use the returned child ID.

Source

Thrown at internal/storage/domain/db/child_counter.go:81

		var id string
		if err := rows.Scan(&id); err != nil {
			return "", fmt.Errorf("db: ChildCounterSQLRepository.NextChildID: scan: %w", err)
		}
		if n, ok := parseChildSuffix(id); ok && n > lastChild {
			lastChild = n
		}
	}
	if err := rows.Err(); err != nil {
		return "", fmt.Errorf("db: ChildCounterSQLRepository.NextChildID: rows: %w", err)
	}

	next := lastChild + 1
	//nolint:gosec // G201: counterTable is one of two hardcoded constants
	if _, err := r.runner.ExecContext(ctx, fmt.Sprintf(`
		INSERT INTO %s (parent_id, last_child) VALUES (?, ?)
		ON DUPLICATE KEY UPDATE last_child = ?
	`, counterTable), parentID, next, next); err != nil {
		return "", fmt.Errorf("db: ChildCounterSQLRepository.NextChildID: upsert counter for %s: %w", parentID, err)
	}

	return fmt.Sprintf("%s.%d", parentID, next), nil
}

func (r *childCounterSQLRepositoryImpl) parentIsActiveWisp(ctx context.Context, parentID string) (bool, error) {
	var probe int
	err := r.runner.QueryRowContext(ctx, "SELECT 1 FROM wisps WHERE id = ? LIMIT 1", parentID).Scan(&probe)
	switch {
	case err == nil:
		return true, nil
	case errors.Is(err, sql.ErrNoRows):
		return false, nil
	case dberrors.IsTableNotExist(err):
		return false, nil
	default:
		return false, err
	}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Run schema migrations to ensure `child_counters` and `wisp_child_counters` tables exist (check dberrors.IsTableNotExist on the wrapped error).
  2. Retry under contention; serialize concurrent child-creation for the same parent (lock or retry with backoff).
  3. Verify the connection is writable (not a read-only replica) and the server has disk space.
  4. Check the wrapped error for lock-wait-timeout and tune innodb_lock_wait_timeout or reduce transaction scope.

Example fix

// before: counter table missing after manual DB setup
// ERROR: Table 'beads.child_counters' doesn't exist

// after: ensure migrations ran before repository use
if err := db.Migrate(conn); err != nil { log.Fatal(err) }
Defensive patterns

Strategy: validation

Validate before calling

var exists int
if err := conn.QueryRowContext(ctx,
    "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'child_counters'").Scan(&exists); err != nil || exists == 0 {
    return fmt.Errorf("child_counters table missing; run migrations")
}

Type guard

func isUpsertError(err error) bool {
    return err != nil && strings.Contains(err.Error(), "NextChildID: upsert counter")
}

Try / catch

id, err := repo.NextChildID(ctx, parentID, opts)
if err != nil && isUpsertError(err) {
    if dberrors.IsTableNotExist(err) { migrate(); return repo.NextChildID(ctx, parentID, opts) }
    return err // lock contention / read-only: surface to caller
}

Prevention

When it happens

Trigger: Calling NextChildID when the counter table is missing (schema not migrated), the write is rejected (read-only replica, disk full, lock wait timeout), or the context is canceled during the write.

Common situations: Running against a database missing the child_counters/wisp_child_counters migration; duplicate-key/lock contention under concurrent child creation; read-only connection used for a write.

Related errors


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