Skip to content

feat: replace sidekiq reaper+recovery with atomic ClaimSidekiqJob, per-owner heartbeat fencing#5000

Closed
BearTS wants to merge 1 commit into
graphite-base/5000from
07-07-feat_respect_sidekiq_over_clustering
Closed

feat: replace sidekiq reaper+recovery with atomic ClaimSidekiqJob, per-owner heartbeat fencing#5000
BearTS wants to merge 1 commit into
graphite-base/5000from
07-07-feat_respect_sidekiq_over_clustering

Conversation

@BearTS

@BearTS BearTS commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the single-node Sidekiq reaper + RecoverIncomplete pattern with a cluster-safe dispatcher backed by atomic job claiming. Previously, multiple nodes could double-run the same job on startup recovery, and a revived stale owner could overwrite state written by the node that re-claimed the job. This PR introduces an owner_id column and per-operation owner fencing so exactly one node runs each job at any time, even across crashes and restarts.

Changes

  • owner_id column and migration: Added OwnerID to TableSidekiqJob and a new migration (add_sidekiq_owner_id) that adds the column and an index. All mutating operations (progress, complete, fail, heartbeat) are now fenced on owner_id so a revived stale owner affects 0 rows.

  • ClaimSidekiqJob replaces MarkSidekiqJobRunning: The new method is an atomic conditional UPDATE that succeeds only when the job is pending, or running but stale (heartbeat older than staleBefore). A job with a live owner yields RowsAffected == 0. started_at is set via COALESCE so resumes preserve the original start time. Returns a boolean indicating whether this caller won the claim.

  • HeartbeatSidekiqJob: New method that bumps updated_at for a job the caller still owns. Returns false when ownership has been lost, which the runner uses as a signal to cancel the in-flight handler context.

  • ListClaimableSidekiqJobs replaces ListIncompleteSidekiqJobs: Now filters to pending jobs and running-but-stale jobs (orphaned by a dead owner), rather than all non-terminal jobs.

  • Per-job heartbeat goroutine: Each claimed job runs a ticker that periodically calls HeartbeatSidekiqJob. If the heartbeat reports lost ownership, the job's context is cancelled so the handler stops at its next checkpoint.

  • StartDispatcher replaces StartReaper + RecoverIncomplete: Runs one scan immediately on startup (subsuming crash recovery) then on a fixed interval. Each node lists claimable jobs and spawns goroutines; the atomic claim inside execute ensures exactly one node wins per job — no leader election required.

  • Poison-job guard: Jobs that have reached MaxAttempts (5) are marked failed permanently rather than retried indefinitely.

  • ownerID per process: Runner generates a UUID on construction that identifies it for the lifetime of the process. All store calls pass this ID for fencing.

  • Tests updated: fakeStore now models atomic claim and owner fencing. New tests cover: dispatcher picking up pending and stale jobs, claim exclusivity across two owners, single-winner race between two runners sharing a store, and heartbeat-triggered handler cancellation on lost ownership.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Chore/CI

Affected areas

  • Core (Go)
  • Transports (HTTP)
  • Providers/Integrations
  • Plugins
  • UI (React)
  • Docs

How to test

go test ./framework/sidekiq/...
go test ./framework/configstore/...
go test ./transports/bifrost-http/...

Key scenarios to validate:

  • A pending job is claimed and run exactly once when two runners dispatch concurrently (TestRunnerRaceSingleWinner).
  • A stale running job (orphaned by a dead owner) is re-claimed and run by the dispatcher (TestDispatcherRunsClaimableJobs).
  • A handler is cancelled when its owning runner loses the heartbeat fence (TestHeartbeatCancelsOnLostOwnership).
  • A job that has been attempted 5 times is marked failed and not retried.
  • The add_sidekiq_owner_id migration applies cleanly on a database that already has the sidekiq table.

Breaking changes

  • Yes
  • No

The ConfigStore interface has changed: MarkSidekiqJobRunning, ListIncompleteSidekiqJobs are removed; ClaimSidekiqJob, HeartbeatSidekiqJob, ListClaimableSidekiqJobs are added; UpdateSidekiqJobProgress, CompleteSidekiqJob, and FailSidekiqJob now require an ownerID argument. Any external implementation of ConfigStore or sidekiq.Store must be updated. BifrostHTTPServer.SidekiqReaperStop is renamed to SidekiqDispatcherStop.

Security considerations

owner_id is a process-scoped UUID generated at startup. It is stored in the database but is not user-facing and carries no elevated privilege. The fencing it provides prevents a compromised or stale node from overwriting job state owned by a healthy node.

Checklist

  • I read docs/contributing/README.md and followed the guidelines
  • I added/updated tests where appropriate
  • I updated documentation where needed
  • I verified builds succeed (Go and UI)
  • I verified the CI pipeline passes locally if applicable

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR switches Sidekiq job handling to owner-fenced claiming and heartbeat-based execution, adds the owner_id schema change, updates store and runner APIs, and rewires server startup/shutdown around a dispatcher instead of reaper/recovery logic.

Changes

Sidekiq ownership claim/heartbeat model

Layer / File(s) Summary
Schema migration for owner_id
framework/configstore/migrations.go
Adds a migration step and rollback path for sidekiq.owner_id and its index.
TableSidekiqJob schema update
framework/configstore/tables/sidekiq.go
Adds OwnerID to persisted Sidekiq jobs.
ConfigStore interface contract for ownership
framework/configstore/store.go
Replaces mark-running/incomplete APIs with claim, heartbeat, and claimable-list methods; ownerID is required for progress and terminal updates.
RDBConfigStore ownership-fenced implementation
framework/configstore/sidekiq.go
Implements atomic claim/heartbeat and owner-scoped progress, complete, and fail updates.
Sidekiq runner claim/heartbeat/dispatcher rewrite
framework/sidekiq/sidekiq.go
Generates a per-process owner ID, claims jobs atomically, heartbeats during execution, enforces retry limits, and dispatches from claimable jobs.
Sidekiq fake store and lifecycle tests
framework/sidekiq/sidekiq_test.go
Rebuilds the in-memory fake store and adds claim, heartbeat, dispatch, contention, shutdown, and reaper coverage.
HTTP mock config store updates
transports/bifrost-http/lib/config_test.go
Updates the config-store mock to match the new claim/heartbeat/owner-aware API.
Server dispatcher wiring
transports/bifrost-http/server/server.go
Starts and stops the Sidekiq dispatcher through server bootstrap and shutdown.

Estimated code review effort: 4 (Complex) | ~75 minutes

Suggested reviewers: danpiths, akshaydeo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: replacing Sidekiq reaping/recovery with atomic claiming and owner heartbeat fencing.
Description check ✅ Passed The description covers the summary, changes, type of change, affected areas, testing, breaking changes, security, and checklist, with only minor optional sections missing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 07-07-feat_respect_sidekiq_over_clustering

Comment @coderabbitai help to get the list of available commands.

BearTS commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@BearTS BearTS changed the title feat: respect sidekiq over clustering feat: replace sidekiq reaper+recovery with atomic ClaimSidekiqJob, per-owner heartbeat fencing, and cluster-safe dispatcher Jul 7, 2026
@BearTS BearTS changed the title feat: replace sidekiq reaper+recovery with atomic ClaimSidekiqJob, per-owner heartbeat fencing, and cluster-safe dispatcher feat: replace sidekiq reaper+recovery with atomic ClaimSidekiqJob, per-owner heartbeat fencing Jul 7, 2026
@BearTS BearTS marked this pull request as ready for review July 7, 2026 12:39
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

Core cluster-safety logic is correct and well-tested; two open items from earlier review rounds remain unresolved before merging.

The atomic claim, owner fencing, heartbeat goroutine, and inflight dedup are all implemented correctly. Previous concerns about the staleAfter data race and unbounded goroutine accumulation are addressed in this diff. Two items flagged in prior rounds are still outstanding: the migration index on the sidekiq table is created with a blocking statement rather than CONCURRENTLY, which can lock the table on deployments with accumulated history; and TestConcurrencySemaphoreBound has a WaitGroup counter underflow that causes the third job to land in failed rather than completed, making the test assert incorrect runtime behaviour while appearing to pass.

framework/configstore/migrations.go (blocking index creation) and framework/sidekiq/sidekiq_test.go (WaitGroup underflow in TestConcurrencySemaphoreBound)

Important Files Changed

Filename Overview
framework/sidekiq/sidekiq.go Core refactor: replaces StartReaper+RecoverIncomplete with StartDispatcher, adds atomic claim, per-job heartbeat goroutine, inflight dedup, and poison-job guard. Data race on staleAfter is fixed via atomic.Int64. Logic is correct; the remaining open items from earlier review rounds are addressed here.
framework/configstore/sidekiq.go New ClaimSidekiqJob (conditional UPDATE, COALESCE for started_at), HeartbeatSidekiqJob, and ListClaimableSidekiqJobs methods; existing progress/complete/fail now fence on owner_id AND status=running. SQL logic is correct; MarkStaleSidekiqJobsFailed is retained in the configstore but is no longer called by the dispatcher.
framework/configstore/sidekiq_test.go New integration tests cover claim exclusivity, stale reclaim, heartbeat rejection, owner fencing for complete/fail, and MarkStaleSidekiqJobsFailed. Good coverage of the new behaviour against a real SQLite DB via AutoMigrate.
framework/sidekiq/sidekiq_test.go New unit tests cover dispatcher pickup, claim exclusivity, single-winner race, heartbeat-triggered cancellation, concurrency semaphore bound, and inflight dedup. TestConcurrencySemaphoreBound has a WaitGroup counter underflow (noted in prior review) that causes job-extra to land in failed state while the test reports a pass.
framework/configstore/migrations.go Adds migrationAddSidekiqOwnerID: addColumnIfNotExists + CREATE INDEX IF NOT EXISTS idx_sidekiq_owner. Index creation still uses a blocking statement rather than CONCURRENTLY (noted in prior review under migration-large-table-safety).
framework/configstore/store.go ConfigStore interface updated: MarkSidekiqJobRunning and ListIncompleteSidekiqJobs removed, ClaimSidekiqJob/HeartbeatSidekiqJob/ListClaimableSidekiqJobs added, ownerID parameter added to progress/complete/fail. Breaking change is correctly documented.
transports/bifrost-http/server/server.go Bootstrap now calls StartDispatcher instead of StartReaper+RecoverIncomplete goroutine; SidekiqReaperStop renamed to SidekiqDispatcherStop. Intentional and documented breaking rename.
transports/bifrost-http/lib/config_test.go MockConfigStore updated to satisfy the new ConfigStore interface signatures. Stub implementations are correct.
framework/configstore/tables/sidekiq.go Adds OwnerID field with correct GORM column/index tags. omitempty on JSON tag is appropriate since owner_id is empty until claimed.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant D as StartDispatcher (ticker)
    participant Store as ConfigStore (DB)
    participant E as execute goroutine
    participant H as Handler
    participant HB as Heartbeat goroutine

    D->>Store: ListClaimableSidekiqJobs(staleBefore)
    Store-->>D: [pending jobs, stale-running jobs]
    D->>E: spawn(job) [if not inflight]
    note over E: acquire semaphore

    E->>Store: ClaimSidekiqJob(id, ownerID, staleBefore)
    note over Store: atomic UPDATE WHERE status=pending OR (status=running AND updated_at lt staleBefore)
    Store-->>E: "claimed=true (RowsAffected==1)"

    E->>HB: startHeartbeat(jobCtx, cancel, id)
    E->>H: fn(jobCtx, job, progress)

    loop every HeartbeatInterval
        HB->>Store: HeartbeatSidekiqJob(id, ownerID)
        Store-->>HB: "ok=true (still owns)"
    end

    H-->>E: finalMetadata, nil
    E->>HB: stopHeartbeat()
    E->>Store: CompleteSidekiqJob(id, ownerID, metadata)
    note over Store: fenced: WHERE owner_id=ownerID AND status=running

    note over HB: ownership lost scenario
    HB->>Store: HeartbeatSidekiqJob(id, ownerID)
    Store-->>HB: "ok=false (owner changed)"
    HB->>E: cancel() cancels jobCtx
    H-->>E: context.Canceled error
    E->>Store: FailSidekiqJob(id, ownerID, ...) returns error: not owner
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant D as StartDispatcher (ticker)
    participant Store as ConfigStore (DB)
    participant E as execute goroutine
    participant H as Handler
    participant HB as Heartbeat goroutine

    D->>Store: ListClaimableSidekiqJobs(staleBefore)
    Store-->>D: [pending jobs, stale-running jobs]
    D->>E: spawn(job) [if not inflight]
    note over E: acquire semaphore

    E->>Store: ClaimSidekiqJob(id, ownerID, staleBefore)
    note over Store: atomic UPDATE WHERE status=pending OR (status=running AND updated_at lt staleBefore)
    Store-->>E: "claimed=true (RowsAffected==1)"

    E->>HB: startHeartbeat(jobCtx, cancel, id)
    E->>H: fn(jobCtx, job, progress)

    loop every HeartbeatInterval
        HB->>Store: HeartbeatSidekiqJob(id, ownerID)
        Store-->>HB: "ok=true (still owns)"
    end

    H-->>E: finalMetadata, nil
    E->>HB: stopHeartbeat()
    E->>Store: CompleteSidekiqJob(id, ownerID, metadata)
    note over Store: fenced: WHERE owner_id=ownerID AND status=running

    note over HB: ownership lost scenario
    HB->>Store: HeartbeatSidekiqJob(id, ownerID)
    Store-->>HB: "ok=false (owner changed)"
    HB->>E: cancel() cancels jobCtx
    H-->>E: context.Canceled error
    E->>Store: FailSidekiqJob(id, ownerID, ...) returns error: not owner
Loading

Reviews (4): Last reviewed commit: "feat: respect sidekiq over clustering" | Re-trigger Greptile

Comment on lines +10406 to +10416
tx = tx.WithContext(ctx)
if err := tx.Exec(`DROP INDEX IF EXISTS idx_sidekiq_owner`).Error; err != nil {
return err
}
return dropColumnIfExists(tx, logger, &tables.TableSidekiqJob{}, "owner_id")
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running %s migration: %w", migrationName, err)
}
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Blocking index creation on potentially large table

CREATE INDEX IF NOT EXISTS idx_sidekiq_owner ON sidekiq (owner_id) acquires an AccessShareLock-conflicting write lock for the entire index build. For a PostgreSQL deployment with accumulated historical job rows, this blocks all concurrent reads and writes on the sidekiq table for the duration of the migration. Per the migration-large-table-safety rule, index creation must use CREATE INDEX CONCURRENTLY where the database supports it.

For SQLite there is no CONCURRENTLY keyword, so the migration should branch on the dialect (or use a helper that already does so), or document that this migration requires a maintenance window if the table is large. The same concern applies to the Rollback's DROP INDEX — it should use DROP INDEX CONCURRENTLY IF EXISTS on PostgreSQL.

Rule Used: Whenever a migration is added or changed, verify i... (source)

Comment thread framework/sidekiq/sidekiq.go
Comment thread framework/sidekiq/sidekiq.go
Comment on lines +194 to 202
// Poison guard: job.Attempts is the count before this claim. Once a job has been
// attempted MaxAttempts times, stop retrying and mark it failed permanently.
if job.Attempts >= MaxAttempts {
r.logger.Warn("sidekiq: job %s (%s) exceeded max attempts (%d), marking failed", job.ID, job.Kind, MaxAttempts)
if ferr := r.store.FailSidekiqJob(r.baseCtx, job.ID, r.ownerID, job.Metadata, fmt.Sprintf("exceeded max attempts (%d)", MaxAttempts)); ferr != nil {
r.logger.Error("sidekiq: failed to fail exhausted job %s: %v", job.ID, ferr)
}
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 MaxAttempts check uses the pre-claim snapshot — no test covers the boundary

job.Attempts is the value captured when the job was listed or enqueued; the ClaimSidekiqJob call on line 184 has already incremented the DB counter by the time this guard runs. When job.Attempts == MaxAttempts - 1 in the snapshot, the 5th run is allowed (correct); when job.Attempts == MaxAttempts, the guard fires and the job is permanently failed (correct). The logic is right, but there is no dedicated test that exercises this path. A test seeding a job with Attempts = MaxAttempts and verifying that execute() calls FailSidekiqJob rather than the handler would prevent regressions here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@BearTS

BearTS commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@BearTS BearTS force-pushed the 07-07-feat_respect_sidekiq_over_clustering branch from 8e175f9 to 9db4367 Compare July 8, 2026 07:53
@BearTS BearTS force-pushed the 07-07-feat_respect_sidekiq_over_clustering branch from 9db4367 to 91634f5 Compare July 8, 2026 08:03
@BearTS BearTS force-pushed the 07-07-feat_add_sidekiq_job_runner branch from 6257422 to e7bfb8f Compare July 8, 2026 09:43
@BearTS BearTS force-pushed the 07-07-feat_respect_sidekiq_over_clustering branch from 91634f5 to c721fd7 Compare July 8, 2026 09:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
framework/sidekiq/sidekiq.go (1)

180-199: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound goroutines, not just active handlers.

dispatchOnce can list all claimable jobs and spawn starts one goroutine per job before acquiring sem. With a large backlog, this can create an unbounded number of goroutines blocked on the semaphore; acquire a slot before launching or add a bounded dispatch batch/queue.

One localized way to bound spawned goroutines
 func (r *Runner) spawn(job tables.TableSidekiqJob) {
 	if !r.tryMarkInflight(job.ID) {
 		return
 	}
+	select {
+	case r.sem <- struct{}{}:
+	case <-r.baseCtx.Done():
+		r.clearInflight(job.ID)
+		return
+	default:
+		r.clearInflight(job.ID)
+		return
+	}
 	r.wg.Add(1)
 	go func() {
 		defer r.wg.Done()
 		defer r.clearInflight(job.ID)
-		select {
-		case r.sem <- struct{}{}:
-		case <-r.baseCtx.Done():
-			return
-		}
 		defer func() { <-r.sem }()
 		r.execute(job)
 	}()
 }

As per coding guidelines, Go concurrency should use bounded goroutines/channels; as per path instructions, framework/** changes should preserve bounded resource usage.

Also applies to: 346-357

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/sidekiq/sidekiq.go` around lines 180 - 199, The goroutine spawning
in Runner.spawn is still unbounded because each claimable job launches a
goroutine before acquiring sem, which can pile up blocked goroutines under
backlog. Fix this by ensuring concurrency is bounded before goroutine creation,
either by acquiring a semaphore slot in dispatchOnce/spawn prior to starting the
goroutine or by introducing a bounded dispatch batch/queue so only a limited
number of jobs are handed off at once. Keep the behavior centered around
Runner.spawn and dispatchOnce so the node never accumulates more goroutines than
the configured concurrency.

Sources: Coding guidelines, Path instructions

framework/configstore/sidekiq.go (1)

108-123: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Fence progress writes on status = running too. MarkStaleSidekiqJobsFailed can flip a running job to failed without changing owner_id, so the previous owner can still call UpdateSidekiqJobProgress and overwrite metadata/updated_at on a terminal row. Match CompleteSidekiqJob and FailSidekiqJob here to keep stale jobs from being updated after the reaper has failed them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/configstore/sidekiq.go` around lines 108 - 123, Update
UpdateSidekiqJobProgress in RDBConfigStore so progress writes are gated on the
job still being running, not just matching id and owner_id. Add the same status
check used by CompleteSidekiqJob and FailSidekiqJob to the query before calling
Updates, so MarkStaleSidekiqJobsFailed cannot leave a failed row writable by the
previous owner. Keep the existing not-found/ownership error path, but ensure
terminal jobs are excluded from metadata and updated_at changes.
🧹 Nitpick comments (3)
framework/configstore/tables/sidekiq.go (1)

29-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Name the GORM index tag to match the migration's index name.

OwnerID's index tag is unnamed, while the raw-SQL migration creates a specifically named idx_sidekiq_owner index. Other fields in this struct (Kind, Status, UpdatedAt) explicitly name their index tags. If GORM's own naming convention is ever exercised (e.g. via the default: dialect fallback in migrationAddSidekiqTable that calls tx.Migrator().CreateTable), this mismatch could produce a duplicate, differently-named index on the same column.

♻️ Proposed fix
-	OwnerID     string     `gorm:"column:owner_id;type:text;index" json:"owner_id,omitempty"`
+	OwnerID     string     `gorm:"column:owner_id;type:text;index:idx_sidekiq_owner" json:"owner_id,omitempty"`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/configstore/tables/sidekiq.go` at line 29, The OwnerID field in
SidekiqTable uses an unnamed GORM index tag, which can diverge from the
migration’s explicit idx_sidekiq_owner index name. Update the OwnerID struct tag
in SidekiqTable to use the same named index pattern as Kind, Status, and
UpdatedAt so migrationAddSidekiqTable and tx.Migrator().CreateTable produce
consistent index naming and avoid duplicates.
framework/configstore/store.go (1)

666-675: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the ownership/fencing contract for the new Sidekiq methods.

ClaimSidekiqJob, HeartbeatSidekiqJob, and the owner-fenced UpdateSidekiqJobProgress/CompleteSidekiqJob/FailSidekiqJob introduce a new atomic-claim + fencing contract, but unlike other concurrency-sensitive interface methods in this file (e.g. TryAcquireLock, ConsentOAuth2AuthorizeRequest), they lack doc comments describing what the bool return means (claim won vs. lost), what "stale" means relative to staleBefore, and whether an ownership mismatch on the mutating methods is a silent no-op or returns an error. This matters especially since this contract spans multiple layers (RDB implementation, runner, dispatcher) per the PR's stacked design.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/configstore/store.go` around lines 666 - 675, Document the new
Sidekiq ownership/fencing contract on the interface methods in Store: add
concise comments for ClaimSidekiqJob, HeartbeatSidekiqJob,
UpdateSidekiqJobProgress, CompleteSidekiqJob, and FailSidekiqJob describing what
the bool return means, how staleBefore defines staleness, and how ownership
mismatches are handled. Mirror the style used by other concurrency-sensitive
methods like TryAcquireLock and ConsentOAuth2AuthorizeRequest, and make sure the
comments clearly state the atomic-claim plus fenced-owner behavior that the RDB
implementation, runner, and dispatcher rely on.
framework/sidekiq/sidekiq.go (1)

135-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid implying Enqueue always wins the claim.

A dispatcher on another node can claim the pending row between CreateSidekiqJob and this node’s execute; the atomic claim decides the winner.

Suggested comment adjustment
-// spawns the job directly and wins the claim (the row is pending and unowned).
+// spawns the job directly; the first runner to atomically claim the pending row
+// processes it, so a dispatcher on another node may still win the race.

As per coding guidelines, Go changes should preserve clear ownership semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/sidekiq/sidekiq.go` around lines 135 - 136, Adjust the comment in
the sidekiq enqueue flow to avoid saying the enqueuing node “wins the claim”
unconditionally; update the wording near CreateSidekiqJob and execute to reflect
that the atomic claim determines ownership and another dispatcher can claim the
pending row before this node executes. Keep the semantics clear that Enqueue
only creates the job and background execution/claim ownership is decided later.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@framework/configstore/sidekiq_test.go`:
- Around line 177-190: Update UpdateSidekiqJobProgress to only write progress
when the job is still in the running state, not just when owner_id matches. Add
the status=f.running fence in the Sidekiq job update path so completed or failed
jobs cannot have metadata overwritten, and keep the owner check intact. Extend
TestUpdateSidekiqJobProgress in sidekiq_test.go to cover a terminal-state case
by asserting progress updates are rejected once the job is no longer running,
using the existing create/claim/update flow helpers.

In `@framework/sidekiq/sidekiq_test.go`:
- Around line 449-454: The handler registered via r.Register("k", ...) is
calling inFlight.Done() for every job, but inFlight.Add(maxConcurrent) only
covers the first two expected executions, so the extra job can drive the
WaitGroup negative. Update the test’s synchronization in sidekiq_test.go so the
"k" handler only signals the WaitGroup for the intended in-flight jobs (or
conditionally skips Done() for job-extra), while still blocking on gate and
preserving the concurrent job sequencing.
- Around line 111-121: The fake progress update path in
fakeStore.UpdateSidekiqJobProgress only checks ownership, so it still accepts
checkpoints after a job has left running state. Update this method to also
verify the job is still running before mutating metadata/progress, matching the
terminal-write guard used elsewhere in the Sidekiq fake. Keep the existing
ownership check and reject late progress updates for completed/failed/reaped
jobs so runner tests exercise the failure path correctly.

In `@transports/bifrost-http/server/server.go`:
- Around line 1901-1905: The server error exit path in Server.Start leaves the
Sidekiq dispatcher running, so clean up the background worker on every Start
return path. Update the logic around SidekiqRunner.StartDispatcher in
Server.Start to ensure SidekiqDispatcherStop is called and
SidekiqRunner.Shutdown is invoked before returning on any error after the
dispatcher has been started. Make the cleanup safe to call only when
SidekiqRunner is non-nil, and preserve the existing lifecycle/cancellation
behavior for the dispatcher and HTTP server shutdown flow.

---

Outside diff comments:
In `@framework/configstore/sidekiq.go`:
- Around line 108-123: Update UpdateSidekiqJobProgress in RDBConfigStore so
progress writes are gated on the job still being running, not just matching id
and owner_id. Add the same status check used by CompleteSidekiqJob and
FailSidekiqJob to the query before calling Updates, so
MarkStaleSidekiqJobsFailed cannot leave a failed row writable by the previous
owner. Keep the existing not-found/ownership error path, but ensure terminal
jobs are excluded from metadata and updated_at changes.

In `@framework/sidekiq/sidekiq.go`:
- Around line 180-199: The goroutine spawning in Runner.spawn is still unbounded
because each claimable job launches a goroutine before acquiring sem, which can
pile up blocked goroutines under backlog. Fix this by ensuring concurrency is
bounded before goroutine creation, either by acquiring a semaphore slot in
dispatchOnce/spawn prior to starting the goroutine or by introducing a bounded
dispatch batch/queue so only a limited number of jobs are handed off at once.
Keep the behavior centered around Runner.spawn and dispatchOnce so the node
never accumulates more goroutines than the configured concurrency.

---

Nitpick comments:
In `@framework/configstore/store.go`:
- Around line 666-675: Document the new Sidekiq ownership/fencing contract on
the interface methods in Store: add concise comments for ClaimSidekiqJob,
HeartbeatSidekiqJob, UpdateSidekiqJobProgress, CompleteSidekiqJob, and
FailSidekiqJob describing what the bool return means, how staleBefore defines
staleness, and how ownership mismatches are handled. Mirror the style used by
other concurrency-sensitive methods like TryAcquireLock and
ConsentOAuth2AuthorizeRequest, and make sure the comments clearly state the
atomic-claim plus fenced-owner behavior that the RDB implementation, runner, and
dispatcher rely on.

In `@framework/configstore/tables/sidekiq.go`:
- Line 29: The OwnerID field in SidekiqTable uses an unnamed GORM index tag,
which can diverge from the migration’s explicit idx_sidekiq_owner index name.
Update the OwnerID struct tag in SidekiqTable to use the same named index
pattern as Kind, Status, and UpdatedAt so migrationAddSidekiqTable and
tx.Migrator().CreateTable produce consistent index naming and avoid duplicates.

In `@framework/sidekiq/sidekiq.go`:
- Around line 135-136: Adjust the comment in the sidekiq enqueue flow to avoid
saying the enqueuing node “wins the claim” unconditionally; update the wording
near CreateSidekiqJob and execute to reflect that the atomic claim determines
ownership and another dispatcher can claim the pending row before this node
executes. Keep the semantics clear that Enqueue only creates the job and
background execution/claim ownership is decided later.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c53b470-6b2a-45fb-8d64-1660e9044ffa

📥 Commits

Reviewing files that changed from the base of the PR and between e7bfb8f and c721fd7.

📒 Files selected for processing (9)
  • framework/configstore/migrations.go
  • framework/configstore/sidekiq.go
  • framework/configstore/sidekiq_test.go
  • framework/configstore/store.go
  • framework/configstore/tables/sidekiq.go
  • framework/sidekiq/sidekiq.go
  • framework/sidekiq/sidekiq_test.go
  • transports/bifrost-http/lib/config_test.go
  • transports/bifrost-http/server/server.go

Comment on lines +177 to +190
func TestUpdateSidekiqJobProgress(t *testing.T) {
store := setupSidekiqTestStore(t)
ctx := context.Background()
require.NoError(t, store.CreateSidekiqJob(ctx, &tables.TableSidekiqJob{ID: "j1", Kind: "k"}))
_, err := store.ClaimSidekiqJob(ctx, "j1", "owner-A", time.Now().Add(-time.Minute))
require.NoError(t, err)

require.NoError(t, store.UpdateSidekiqJobProgress(ctx, "j1", "owner-A", `{"cursor":42}`))
assert.Equal(t, `{"cursor":42}`, getJob(t, store, "j1").Metadata)

// A stale/non-owner cannot advance progress.
assert.Error(t, store.UpdateSidekiqJobProgress(ctx, "j1", "owner-B", `{"cursor":99}`))
assert.Equal(t, `{"cursor":42}`, getJob(t, store, "j1").Metadata, "metadata unchanged by non-owner")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant files first
git ls-files framework/configstore | sed -n '1,200p'
echo '---'
ast-grep outline framework/configstore/sidekiq_test.go --view expanded
echo '---'
ast-grep outline framework/configstore | sed -n '1,200p'

# Locate the implementation of the progress update path and terminal-state transitions
echo '--- rg UpdateSidekiqJobProgress/CompleteSidekiqJob/Fail/Reap ---'
rg -n "UpdateSidekiqJobProgress|CompleteSidekiqJob|FailSidekiqJob|ReapSidekiqJob|ClaimSidekiqJob" framework/configstore -S

# Show the implementation around the progress update logic and terminal updates
echo '--- file sizes ---'
wc -l framework/configstore/*.go framework/configstore/*_test.go || true
echo '--- sidekiq implementation slice ---'
sed -n '1,260p' framework/configstore/sidekiq.go
echo '--- sidekiq test slice ---'
sed -n '1,260p' framework/configstore/sidekiq_test.go

# Read only the specific sections if the files are large
echo '--- focused impl slices ---'
sed -n '1,220p' framework/configstore/sidekiq.go | nl -ba | sed -n '1,220p'

Repository: maximhq/bifrost

Length of output: 50373


Guard progress updates on terminal jobs. UpdateSidekiqJobProgress only checks owner_id, so the same owner can still overwrite metadata after the job has been completed or failed. Add a status = running fence here and cover the terminal-state case in framework/configstore/sidekiq_test.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/configstore/sidekiq_test.go` around lines 177 - 190, Update
UpdateSidekiqJobProgress to only write progress when the job is still in the
running state, not just when owner_id matches. Add the status=f.running fence in
the Sidekiq job update path so completed or failed jobs cannot have metadata
overwritten, and keep the owner check intact. Extend
TestUpdateSidekiqJobProgress in sidekiq_test.go to cover a terminal-state case
by asserting progress updates are rejected once the job is no longer running,
using the existing create/claim/update flow helpers.

Source: Path instructions

Comment on lines +111 to 121
func (f *fakeStore) UpdateSidekiqJobProgress(_ context.Context, id, ownerID, metadata string) error {
f.mu.Lock()
defer f.mu.Unlock()
j, ok := f.jobs[id]
if !ok || j.owner != ownerID {
return errors.New("not owned by caller")
}
j.metadata = metadata
j.updatedAt = time.Now()
f.progress[id] = metadata
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fence fake progress updates on running status too.

The fake currently allows the same owner to checkpoint a completed/failed job, so runner tests can miss late-progress mutations after terminal/reaped state. Match the terminal-write guard and reject progress unless the job is still running.

Suggested fix
 	j, ok := f.jobs[id]
-	if !ok || j.owner != ownerID {
-		return errors.New("not owned by caller")
+	if !ok || j.owner != ownerID || j.status != tables.SidekiqStatusRunning {
+		return errors.New("not owned by caller or no longer running")
 	}

As per path instructions, framework tests should cover failure paths and preserve data ownership semantics.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (f *fakeStore) UpdateSidekiqJobProgress(_ context.Context, id, ownerID, metadata string) error {
f.mu.Lock()
defer f.mu.Unlock()
j, ok := f.jobs[id]
if !ok || j.owner != ownerID {
return errors.New("not owned by caller")
}
j.metadata = metadata
j.updatedAt = time.Now()
f.progress[id] = metadata
return nil
func (f *fakeStore) UpdateSidekiqJobProgress(_ context.Context, id, ownerID, metadata string) error {
f.mu.Lock()
defer f.mu.Unlock()
j, ok := f.jobs[id]
if !ok || j.owner != ownerID || j.status != tables.SidekiqStatusRunning {
return errors.New("not owned by caller or no longer running")
}
j.metadata = metadata
j.updatedAt = time.Now()
f.progress[id] = metadata
return nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/sidekiq/sidekiq_test.go` around lines 111 - 121, The fake progress
update path in fakeStore.UpdateSidekiqJobProgress only checks ownership, so it
still accepts checkpoints after a job has left running state. Update this method
to also verify the job is still running before mutating metadata/progress,
matching the terminal-write guard used elsewhere in the Sidekiq fake. Keep the
existing ownership check and reject late progress updates for
completed/failed/reaped jobs so runner tests exercise the failure path
correctly.

Source: Path instructions

Comment on lines +449 to +454
var inFlight sync.WaitGroup

r.Register("k", func(_ context.Context, _ tables.TableSidekiqJob, _ ProgressFunc) (string, error) {
inFlight.Done() // signal that we entered the handler
<-gate // block until the test releases us
return "", nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid a negative WaitGroup when the extra job runs.

inFlight.Add(maxConcurrent) accounts for only the first two handlers, but the handler calls inFlight.Done() for job-extra as well. Once the gate is released, the extra handler can drive the counter negative and panic or be recovered as a failed job.

Suggested fix
-	r.Register("k", func(_ context.Context, _ tables.TableSidekiqJob, _ ProgressFunc) (string, error) {
-		inFlight.Done()   // signal that we entered the handler
-		<-gate            // block until the test releases us
+	r.Register("k", func(_ context.Context, job tables.TableSidekiqJob, _ ProgressFunc) (string, error) {
+		if job.ID != "job-extra" {
+			inFlight.Done() // signal that the first batch entered the handler
+		}
+		<-gate // block until the test releases us
 		return "", nil
 	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var inFlight sync.WaitGroup
r.Register("k", func(_ context.Context, _ tables.TableSidekiqJob, _ ProgressFunc) (string, error) {
inFlight.Done() // signal that we entered the handler
<-gate // block until the test releases us
return "", nil
var inFlight sync.WaitGroup
r.Register("k", func(_ context.Context, job tables.TableSidekiqJob, _ ProgressFunc) (string, error) {
if job.ID != "job-extra" {
inFlight.Done() // signal that the first batch entered the handler
}
<-gate // block until the test releases us
return "", nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/sidekiq/sidekiq_test.go` around lines 449 - 454, The handler
registered via r.Register("k", ...) is calling inFlight.Done() for every job,
but inFlight.Add(maxConcurrent) only covers the first two expected executions,
so the extra job can drive the WaitGroup negative. Update the test’s
synchronization in sidekiq_test.go so the "k" handler only signals the WaitGroup
for the intended in-flight jobs (or conditionally skips Done() for job-extra),
while still blocking on gate and preserving the concurrent job sequencing.

Comment on lines +1901 to +1905
// Start the Sidekiq dispatcher: on every node it periodically claims pending and
// stale (orphaned) jobs, with an atomic claim guaranteeing exactly one node runs
// each job. Subsumes startup recovery of jobs left behind by a crash or restart.
if s.SidekiqRunner != nil {
s.SidekiqReaperStop = s.SidekiqRunner.StartReaper(sidekiq.ReaperInterval, sidekiq.StaleAfter)
go func() {
if err := s.SidekiqRunner.RecoverIncomplete(ctx); err != nil {
logger.Error("sidekiq: failed to recover incomplete provisioning jobs: %v", err)
}
}()
s.SidekiqDispatcherStop = s.SidekiqRunner.StartDispatcher(sidekiq.DispatchInterval, sidekiq.StaleAfter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Stop the dispatcher on every Start exit path.

Line 1905 starts a dispatcher goroutine that immediately scans and later claims jobs, but the server-error branch at Lines 2024-2031 returns without calling SidekiqDispatcherStop() or SidekiqRunner.Shutdown(). If Serve exits with an error while the process continues, this node can keep claiming Sidekiq jobs after the HTTP server is down.

Proposed cleanup for the server-error path
 	case err := <-errChan:
 		if s.IntegrationHandler != nil {
 			s.IntegrationHandler.Close()
 		}
+		if s.cancel != nil {
+			s.cancel()
+		}
+		if s.SidekiqDispatcherStop != nil {
+			logger.Info("stopping sidekiq dispatcher...")
+			s.SidekiqDispatcherStop()
+		}
+		if s.SidekiqRunner != nil {
+			logger.Info("stopping sidekiq runner...")
+			s.SidekiqRunner.Shutdown()
+		}
 		if s.wsPool != nil {
 			s.wsPool.Close()
 		}
 		return err

As per path instructions, transports changes should preserve lifecycle behavior and Go review should check correct context cancellation and bounded background resources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@transports/bifrost-http/server/server.go` around lines 1901 - 1905, The
server error exit path in Server.Start leaves the Sidekiq dispatcher running, so
clean up the background worker on every Start return path. Update the logic
around SidekiqRunner.StartDispatcher in Server.Start to ensure
SidekiqDispatcherStop is called and SidekiqRunner.Shutdown is invoked before
returning on any error after the dispatcher has been started. Make the cleanup
safe to call only when SidekiqRunner is non-nil, and preserve the existing
lifecycle/cancellation behavior for the dispatcher and HTTP server shutdown
flow.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant