Skip to content

fix(cron): reserve job IDs atomically#686

Open
PierrunoYT wants to merge 2 commits into
Gitlawb:mainfrom
PierrunoYT:agent/atomic-cron-job-ids
Open

fix(cron): reserve job IDs atomically#686
PierrunoYT wants to merge 2 commits into
Gitlawb:mainfrom
PierrunoYT:agent/atomic-cron-job-ids

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • reserve cron job IDs atomically with directory creation
  • retry only genuine ID collisions and clean reservations when initial metadata writes fail
  • cover concurrent stores with a frozen clock and reservation cleanup failures

Root cause

Store.Add previously checked for an available ID before creating its directory. Concurrent stores could both observe the same timestamp-based ID as available and then write through the same temporary metadata path.

Validation

  • go fmt ./...
  • go vet ./...
  • go test ./internal/cron -count=1
  • concurrent regression test repeated 20 times
  • govulncheck ./... (no vulnerabilities)
  • full go test ./... run: cron and all other packages pass except the existing Windows process-cleanup failures in internal/tools
  • pinned golangci-lint: existing repository-wide findings tracked in Pinned lint and vulnerability commands cannot analyze the Go 1.26 module #680; no finding in changed files
  • make lint: blocked by the existing Windows-incompatible fmt-check shell command

Fixes #678

Summary by CodeRabbit

  • Bug Fixes
    • Improved job ID allocation to prevent duplicate IDs when jobs are added concurrently.
    • Ensured failed job creation does not leave behind incomplete reservations or leftover data.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PierrunoYT, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ba131926-fc68-42b4-8085-eef2b8a42624

📥 Commits

Reviewing files that changed from the base of the PR and between 55d8c55 and ef7537b.

📒 Files selected for processing (2)
  • internal/cron/store.go
  • internal/cron/store_test.go

Walkthrough

Cron job creation now atomically reserves job directories before writing metadata, retries conflicting IDs, cleans up failed reservations, and adds concurrency and failure-cleanup tests.

Changes

Cron job reservation flow

Layer / File(s) Summary
Atomic ID reservation and cleanup
internal/cron/store.go
Store.Add uses reserveID to create unique job directories atomically, then writes metadata through writeReservedJob, which removes reservations after write failures.
Concurrency and failure validation
internal/cron/store_test.go
Tests verify unique IDs and persisted prompts across concurrent stores, along with cleanup of reservations when metadata writing fails.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StoreAdd
  participant reserveID
  participant FileSystem
  participant writeReservedJob
  StoreAdd->>reserveID: Reserve a unique job directory
  reserveID->>FileSystem: Atomically create directory
  reserveID-->>StoreAdd: Return reserved job ID
  StoreAdd->>writeReservedJob: Write metadata
  writeReservedJob->>FileSystem: Persist metadata
  writeReservedJob->>FileSystem: Remove reservation on failure
Loading

Suggested reviewers: gnanam1990

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements atomic reservation, cleanup on write failure, and concurrent cross-store coverage required by #678.
Out of Scope Changes check ✅ Passed The changes stay focused on cron ID reservation and related tests, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the core change: atomically reserving cron job IDs during Add.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@PierrunoYT PierrunoYT marked this pull request as ready for review July 14, 2026 21:05
Copilot AI review requested due to automatic review settings July 14, 2026 21:05

Copilot AI 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.

Pull request overview

This PR fixes a race in internal/cron where concurrent Store.Add calls could allocate the same timestamp-based job ID by switching from a check-then-create flow to an atomic directory reservation, and adds regression tests to validate concurrent adds and reservation cleanup behavior.

Changes:

  • Replace allocID with reserveID, reserving job IDs atomically via os.Mkdir on the job directory.
  • Add writeReservedJob to clean up reserved job directories when initial metadata persistence fails.
  • Add concurrent-add and reservation-cleanup tests using a frozen clock to force collisions deterministically.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/cron/store.go Atomically reserves job IDs by directory creation and cleans up reservations on write failure.
internal/cron/store_test.go Adds concurrent allocation regression coverage and validates reservation cleanup on metadata write failure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/cron/store.go Outdated
}
return "", fmt.Errorf("reserve cron job id %q: %w", id, err)
}
return "", errors.New("could not allocate a unique cron job id")
Comment thread internal/cron/store.go
func (s *Store) writeReservedJob(job Job) error {
if err := s.writeJob(job); err != nil {
if cleanupErr := os.RemoveAll(s.jobDir(job.ID)); cleanupErr != nil {
return errors.Join(err, fmt.Errorf("remove failed cron job reservation: %w", cleanupErr))

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Coordinate removal with an in-flight reservation
    internal/cron/store.go:102
    reserveID makes the timestamp directory visible before the metadata is written, but Remove treats that empty directory as a committed job and deletes it under a lock that Add never takes. A concurrent zero cron rm <timestamp-id> can therefore report success after deleting the reservation; writeJob then calls MkdirAll, recreates the directory, and persists the job anyway. A subsequent add can also re-reserve that same timestamp while the original writer is still running, putting two writers back on the shared metadata path. Hold the same per-ID lock from reservation through initial persistence, or make removal reliably distinguish and coordinate with an uncommitted reservation, and cover the add/remove/add interleaving.

Address code review on PR Gitlawb#686: reserveID made the timestamp directory
visible via os.Mkdir before metadata.json existed, but Remove (which
takes the per-ID lock before deleting) never coordinated with that
window. A concurrent `zero cron rm <id>` could delete the bare
reservation and report success right after Add reserved it; writeJob's
MkdirAll would then silently resurrect the directory and persist the
job anyway. A second Add could also re-reserve the same id while the
first writer was still running, putting two writers on the same
metadata path.

reserveID now acquires the same per-ID lock (used by Get/Update/
Mutate/Remove/AppendRun) for each candidate id before attempting
os.Mkdir, and returns it still held; Add releases it only after
writeReservedJob commits (or tears down) the reservation. A concurrent
Remove for that id now blocks until the reservation is resolved one
way or the other, and a concurrent Add attempting the same candidate
id serializes through the lock rather than only racing the Mkdir.

Adds two regression tests: one proving Remove waits for and removes
the committed job rather than the bare reservation, and one proving a
second reserveID call for the same candidate id blocks on the shared
lock and moves on to the next candidate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
anandh8x

This comment was marked as outdated.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review

The atomic Mkdir reservation plus holding the per-ID lock from reservation through writeReservedJob closes the race @jatmn flagged on the first commit (55d8c55). Remove can no longer delete a bare reservation while Add is still persisting, and a second Add cannot re-enter the same id until the first writer commits or tears down.

Lock lifecycle: reserveID acquires lockJob before Mkdir, returns the lock held on success, and releases it on collision or non-ErrExist mkdir errors. Add's defer unlock() covers both success and writeReservedJob failure (cleanup runs under the held lock; lock file lives outside the job dir).

Tests: go test ./internal/cron -count=1 -race passes locally, including the concurrent-add (32 goroutines), Add-vs-Remove, lock-serialization, and failed-reservation cleanup regressions.

Two minor follow-ups (non-blocking):

  1. Rename the exhausted-id error from "allocate" → "reserve" (store.go:149).
  2. Include job.ID in the reservation cleanup failure message (store.go:184).

Process note: Issue #678 still lacks the issue-approved label — maintainers should add it before merge per CONTRIBUTING.md.

Otherwise LGTM — ready to merge.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewing against commit ef7537b1. The per-ID lock is now held from reserveID through writeReservedJob (deferred unlock in Add, returned unlock from reserveID, acquired via lockJob before Mkdir), which closes the P1 race I flagged. The two new regression tests cover both halves of the finding — concurrent Remove waiting on the lock, and a second reserveID serializing on the same lock rather than racing the Mkdir — and the existing 32-way concurrent add test still holds. Withdrawing my prior CHANGES_REQUESTED. LGTM.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The atomic reservation is sound — holding the per-ID lock from Mkdir through the metadata.json commit closes the Remove-vs-Add resurrect race, and the rest of the cron suite is green under -race. But TestStoreAddReservesUniqueIDsAcrossConcurrentStores is flaky: the frozen clock (time.Unix(1000,0)) pins all 32 writers to the same base id, so they convoy through one per-ID lock at a time and, under load, the tail waiters blow past the 10s lock timeout (I reproduced it under CPU load with "timed out acquiring job lock for 19700101-001640" and "unique jobs = 27, want 32"). Either dial the concurrency down to ~8 or advance the injected clock per writer so they don't all collide on id 0.

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.

Concurrent cron Add calls can allocate the same job ID

6 participants