fix(cron): preserve schedule pointer when persist_cloud_task fails#2
Open
lovitt wants to merge 8 commits into
Open
fix(cron): preserve schedule pointer when persist_cloud_task fails#2lovitt wants to merge 8 commits into
lovitt wants to merge 8 commits into
Conversation
Schedule#save advances the cron schedule's Redis pointer to the new task before invoking persist_cloud_task. If persist_cloud_task raises (e.g., a transient Cloud Tasks API error during the recursive schedule!), the pointer is left advanced past the original task's job_id while no replacement task exists in the queue. The original task's Cloud Tasks retry then hits expected_instance? == false and silent-aborts, leaving the cron entry dead until something external re-registers it. Two changes in lib/cloudtasker/cron/schedule.rb: 1. Schedule#save captures the previous Redis state and rolls back if persist_cloud_task raises. The original task's retry can then repair the chain via expected_instance?. 2. Schedule#persist_cloud_task creates the replacement task before deleting the old one, so a failure during creation preserves the existing task in Cloud Tasks. Adds a regression test in spec/cloudtasker/cron/job_spec.rb that reproduces the chain break and verifies the fix repairs the chain. All 687 existing tests continue to pass.
Compare the current Redis value against what we wrote before rolling back. If they differ, another process has successfully updated the schedule and we leave their write in place rather than clobbering it. Adds a regression test that simulates a concurrent update during persist_cloud_task and verifies the rollback is skipped.
Replace the TOCTOU compare-and-set in the rescue block with a WATCH/MULTI/EXEC transaction. If a concurrent process updates the schedule pointer between our snapshot and our rollback, Redis aborts the transaction and the concurrent update is preserved. Extracts the rollback into a private rollback_pointer helper to keep save's body readable.
- Capture the prior and new payloads as raw bytes in save and pass them to rollback_pointer, removing the implicit "self.to_h must not mutate" invariant the previous comparison relied on. - Use the block form of conn.watch so an exception inside the WATCH region (e.g., from conn.get) auto-issues UNWATCH instead of leaking WATCH state back to the connection pool. - Tighten the chain-repair regression test to assert on the schedule pointer (observable state) rather than spying on schedule!. - Rubocop cleanups in the new spec.
Lock in the behavior introduced by the rollback fix so a future refactor cannot silently revert it: - Schedule#save (persist success): asserts schedule! runs before CloudTask.delete (the create-before-delete invariant). - Schedule#save (persist raises with prior state): asserts the prior Redis payload is restored and CloudTask.delete is not called. - Schedule#save (persist raises with no prior state): asserts the gid is deleted and id is removed from the schedule set. - Schedule.delete (CloudTask.delete raises): asserts the error propagates and Redis state is preserved (regression-guards a related failure mode the current code does not handle). - Job#execute (schedule! raises): asserts the processing flag is not set, which is the precondition for chain repair via Cloud Tasks retry.
Replace manual create_calls counters with rspec-mocks' and_invoke in the chain-break tests, and replace the call_order array with .ordered in the create-before-delete ordering test. Same coverage, fewer lines, more idiomatic.
Replace direct redis.client.with reach-through in Schedule#rollback_pointer with a thin RedisClient wrapper, so the WATCH/MULTI/EXEC path uses an in-house API rather than bypassing the abstraction. Also drop an unrelated Schedule.delete regression test that crept in.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
If
Schedule#persist_cloud_taskraises (e.g., a transient Cloud Tasks API error), the cron schedule entry is left permanently broken until external re-registration: Redis points at a deleted task and the original task's Cloud Tasks retry silent-aborts viaexpected_instance? == false. We observed this 7 times in 30 days in production.Fix:
Schedule#savesnapshots the prior Redis state and atomically rolls back (viaWATCH/MULTI/EXEC) ifpersist_cloud_taskraises;persist_cloud_taskschedules the replacement task before deleting the old one. No public API changes. All existing tests pass plus 2 new regression tests.Problem
When
Cloudtasker::Cron::Schedule#saveinvokespersist_cloud_task, it advances the cron schedule's Redis pointer to the new task before the recursiveCron::Job#schedule!runs that actually creates the replacement task in the backend. If that recursive call raises for any reason (transient Cloud Tasks API error, network blip, instance termination), Redis is left pointing at a task that has been deleted bypersist_cloud_taskand never replaced.The original task's Cloud Tasks retry then evaluates
expected_instance? == false(becausecron_schedule.job_idwas advanced past the original'sjob_id) and silent-aborts. The schedule entry has no live task in the queue and no path to create one. It stays in this state untilSchedule.load_from_hash!runs again.Production observation
We deploy a Rails app on Google Cloud Run with horizontal autoscaling. Cloudtasker schedules are installed via Puma's
after_bootedcallback (equivalent to theon_bootedexample indocs/CRON_JOBS.md), so each Cloud Run instance runsSchedule.load_from_hash!at boot.Over a 30-day window we identified 7 incidents where one specific
* * * * *cron schedule stopped firing for 28–46 minutes. The last invocation before each gap completed in 8–37 ms, in contrast to the worker's normal 500–3000 ms. Our worker has an app-level guard that returns early if itscloudtasker/cron/job/time_atjob_meta is more than 5 minutes behind wall clock; the rapid exits indicate this guard fired — meaning the cron'stime_athad already drifted significantly past wall-clock time before the chain broke.Every incident window contained Cloud Run cold-start clusters (multiple Puma boots within seconds), which trigger
Schedule.load_from_hash!. Across the same 30-day window we observed ~1,342 cold-start clusters total; only the 7 listed coincided with a chain break, so boot clusters alone are not sufficient.We have not directly proven that the failure mode described above is the cause of every one of these incidents. We identified it as a code path in the gem that can produce the observed symptom, and the regression tests below demonstrate it deterministically.
Code path
In
lib/cloudtasker/cron/schedule.rb:Cron::Job#schedule!creates successor task B and callscron_schedule.update(task_id: B.id, job_id: B.job_id).Schedule#saverunsredis.write(gid, to_h)— Redis now points at B.Schedule#saveevaluates the safety check; ifCloudTask.find(B.id)returns nil,persist_cloud_taskis invoked.persist_cloud_taskcallsCloudTask.delete(B.id), removing B.persist_cloud_taskruns a recursiveJob.new(worker_instance).set(schedule_id: id).schedule!to create a replacement.cron_schedule.job_idis still B from step 2.Cron::Job#executeevaluatesexpected_instance? = retry_instance? || (cron_schedule.job_id == job_id). Both are false (retry_instance?becauseflag(:processing)was never reached; the pointer comparison because of step 2). The retry returns true silently — no further dispatches, noschedule!call.Fix
Two changes in
lib/cloudtasker/cron/schedule.rb:Schedule#savesnapshots the previous Redis state before writing and rolls it back ifpersist_cloud_taskraises. The rollback runs inside a RedisWATCH/MULTI/EXECtransaction so a concurrent process that updated the pointer in the meantime is not clobbered — Redis aborts the transaction and the concurrent update is preserved. The original task's Cloud Tasks retry then matchesexpected_instance?and callsschedule!to repair the chain via the standard cron iteration path.Schedule#persist_cloud_taskcreates the replacement task before deleting the old one. If the recursiveschedule!raises, the existing task in Cloud Tasks is preserved (worst case: one extra silent-aborted dispatch when the orphan task fires).Safety
persist_cloud_tasksucceeds, the rescue clause is never reached.Cron::Job#execute; one matchesexpected_instance?and runs, the other silent-aborts. Net cost: one extra silent-aborted dispatch.WATCH/MULTI/EXECensures that if another process updates the schedule pointer between our snapshot and our rollback, the rollback transaction is aborted by Redis and the concurrent update is preserved.Schedule#savetest contexts inspec/cloudtasker/cron/schedule_spec.rbcontinue to assert their existing behavior. Total: 688 examples, 0 failures.Regression tests
spec/cloudtasker/cron/job_spec.rbadds two tests under adescribe '#execute (chain-break regression)'block:Chain repair via Cloud Tasks retry. Forces the
persist_cloud_taskpath, makes the recursiveCloudTask.createraise, then verifies that on Cloud Tasks' retry of the original task,schedule!is called to repair the chain. Without the rollback fix, the retry silent-aborts and this assertion fails.No clobber on concurrent update. Simulates a concurrent process writing a different pointer to the same gid between the outer save's
redis.writeand thepersist_cloud_taskfailure. Verifies the rollback leaves the concurrent update in place rather than restoringprevious_state. Without theWATCH/MULTI/EXECguard, the rollback unconditionally clobbers the concurrent write and this assertion fails.Compatibility
No public API changes, no new dependencies, no configuration changes. Both modifications are contained in private methods of
Cloudtasker::Cron::Schedule. TheWATCH/MULTI/EXECrollback reaches intoredis.client.withfor direct connection access — a pattern not currently used elsewhere in the gem, but it is the standard Redis idiom for atomic compare-and-swap.