Only cache the deploy spec when the sync changed something - #1495
Conversation
timothysmith0609
left a comment
There was a problem hiding this comment.
The core idea is right and the correctness work behind it is careful. I specifically checked the reachable.last vs Stack#head distinction — has_many :commits carries no order and scope :reachable is a bare where(detached: false), so spec_cache_target and CacheDeploySpecJob really do resolve to the same commit. Good catch, and the rewind-force-push reasoning follows from it. Hoisting appended_commits = [] above the block also correctly captures the inner assignment.
One thing I'd like addressed before merge, because it defeats the escape hatch this PR is built around.
🔴 The refresh path races itself, and can leave the spec stale
stacks#refresh now fires GithubSyncJob (queue default) and CacheDeploySpecJob (queue deploys) concurrently. Different queues, no ordering guarantee. If the direct job wins the race:
CacheDeploySpecJobcaches the spec at the pre-sync head, and holds the dedupe lock for its ~65s runtime (per #1494).GithubSyncJobfinds new commits, sees head movement, enqueues a secondCacheDeploySpecJob.- That one hits the held lock and is dropped by
on_duplicate :drop.
Net result: someone pressed Refresh precisely because the spec was stale or broken, and it stays stale — which is the exact scenario the "human escape hatch preserved" section is protecting. The failure is also invisible: the dropped job reports success.
Threading a force: true (or refresh: true) param through GithubSyncJob and letting the existing enqueue site honour it would run exactly one job, after the sync, and sidesteps the race entirely. It also avoids the two-full-clones case when the head has moved.
🟡 Related: this interacts with #1494 to remove the self-healing property
Flagged in more detail on #1494, but restating the half that belongs here. The skip condition is only as good as the guarantee that something eventually recomputes the spec. With #1494 holding the dedupe lock for the whole runtime, a CacheDeploySpecJob enqueued for a newer head while an older run is in flight is silently dropped — and this PR removes the unconditional re-cache that used to catch it on the next webhook. The spec then stays stale until the next head movement.
cached_deploy_spec.blank? is the right instinct for the "is the cache valid" check; it's just too weak a predicate. Comparing against a persisted "spec computed from sha X" column would make the skip condition exact, make the whole flow self-healing, and remove the need for the direct-enqueue-on-refresh hack above. That's migration-first so it can't be this PR, but it may be worth doing before the two behaviours ship together.
🟢 Description inaccuracy
Inaccessible stacks (404 →
mark_as_inaccessible!) no longer enqueue a cache job they'd immediately no-op on.
Only when cached_deploy_spec is present. A blank-spec inaccessible stack still enqueues, and the job early-returns on inaccessible?. Harmless, but the bullet overstates it.
🟢 Test coverage
The four new cases cover the branch conditions well. The one gap is the interaction above — worth an integration-shaped test that a refresh on a stack whose head has moved ends with the spec at the new head, since that's the property users actually rely on.
Not blocking on anything but the refresh race; the rest is fine as follow-ups.
GithubSyncJob unconditionally enqueued CacheDeploySpecJob, so every sync triggered a full clone and spec evaluation even when it found nothing new: refresh requests, duplicate webhook deliveries, and eventual-consistency double-syncs all re-computed a spec that could not have changed. The deploy spec is a pure function of the committed files at the newest reachable commit. Skip the enqueue when no commits were appended, the newest reachable commit is unchanged, and a cached spec exists. The commit comparison uses the same expression CacheDeploySpecJob resolves for its checkout (commits.reachable.last), so rewind force-pushes that only detach commits still refresh the cache. Explicit refreshes (UI button and API endpoint) pass force_spec_cache: true through GithubSyncJob, which skips the conditional: refreshing is how a stale or broken cached spec gets fixed when the head has not moved. Threading the flag through the sync job rather than enqueuing CacheDeploySpecJob directly guarantees the spec is computed from the post-sync head; a direct enqueue racing the sync on a different queue could cache the pre-sync head and then swallow the sync's follow-up job via the dedupe lock. The flag survives eventual-consistency retries via params.merge.
635a50d to
fb8e257
Compare
|
Addressed in the rebased
|
Stacked on #1494 (which stacks on #1493).
What
GithubSyncJobnow skips theCacheDeploySpecJobenqueue when the sync changed nothing: no commits appended, newest reachable commit unchanged, and a cached spec already present.stacks#refresh, UI and API) enqueueCacheDeploySpecJobdirectly and unconditionally.Why
Every sync ended with an unconditional spec re-cache — a full clone + checkout +
shipit.ymlevaluation (~65s, disk, memory) — even when the sync was a no-op. No-op syncs are common: refresh button/API hits, duplicate webhook deliveries, and GitHub eventual-consistency double-syncs. The deploy spec is a pure function of the committed files at the stack's newest reachable commit; if that commit didn't change, recomputing the spec is guaranteed wasted work.This cuts enqueue volume to be proportional to actual head movement instead of webhook/API chatter, relieving the
deploysqueue that spec-cache jobs share with real deploys.Correctness details
commits.reachable.last&.shabefore/after the sync — the exact commitCacheDeploySpecJobwould check out — so these still refresh the cache. (Deliberately notStack#head, which readscommits.reachable.first= the oldest reachable commit under Rails' implicit ordering.)appended_commits.any?, so appends enqueue even if the head comparison were somehow confounded.cached_deploy_spec.blank?forces the enqueue.force_spec_cache: truethroughGithubSyncJob, which skips the conditional — threading it through the sync (rather than enqueuingCacheDeploySpecJobdirectly, as the first revision did) guarantees the spec is computed from the post-sync head and avoids the cross-queue race where a direct enqueue could cache the pre-sync head and then swallow the sync's follow-up via the dedupe lock. The flag survives eventual-consistency retries.mark_as_inaccessible!) no longer enqueue a cache job they'd immediately no-op on — when a cached spec is already present. A blank-spec inaccessible stack still enqueues and the job early-returns; harmless.Tests
CacheDeploySpecJob