chore: throttle deployment bursts that overloaded the Lagoon core#231
chore: throttle deployment bursts that overloaded the Lagoon core#231dan2k3k4 wants to merge 1 commit into
Conversation
| // whole stale pool never rebuilds in one burst. | ||
| $refreshBatch = max(1, (int) config('polydock.deploy.prewarm_batch', 10)); | ||
|
|
||
| if ($staleCount > 0 && Cache::add('prewarm-refresh-hourly-gate', true, 3600)) { |
There was a problem hiding this comment.
Single global gate starves all apps except the first
Cache::add('prewarm-refresh-hourly-gate', true, 3600) uses one key for every store app. Because PolydockStoreApp::query()...->get() returns rows in primary-key order and no orderBy is applied, the lowest-ID app that has stale instances wins the cache gate on every hour boundary. Every other app with stale instances is blocked for that entire hour. If App A continuously regenerates stale instances (e.g. a pool of 50, batch of 10 → 5 hours of back-to-back wins), App B’s stale instances accumulate unattended for that whole window — the scenario this PR is trying to prevent.
Changing the cache key to 'prewarm-refresh-hourly-gate-'.$app->id gives each store app its own independent hourly gate. The global Lagoon pressure is then num_apps × prewarm_batch per hour; if that ceiling is still too high, the batch size can be tuned, or a separate global counter added on top.
Incident response: bulk deployment triggers overloaded Lagoon with concurrent builds. Two sources, both now staged: - Scheduled redeploys ran up to 50 instances every 10 minutes, and the NULLs-first next_redeploy_at ordering front-loaded entire cohorts (adopted projects, newly enabled cadence) in one burst. Now: max 10 per run (POLYDOCK_DEPLOY_MAX_PER_RUN default lowered), hourly schedule, and most-outdated-first ordering (never-redeployed counts as oldest, then by oldest last deployment) so backlogs drain smoothly. - Pre-warm refresh queued EVERY stale instance for removal at once and recreated the full deficit in a single pass — each recreation is a full Lagoon create+deploy. Now: one refresh batch per hour globally (cache gate), batch size capped (POLYDOCK_PREWARM_BATCH default 10, oldest instances first via the removal query's existing ordering), and the creation loop is capped per app per pass — in-progress instances count toward the pool, so large deficits fill over successive passes. Both behaviors are test-pinned: oldest-first trigger order, per-run caps, the hourly refresh gate (including the next batch after the hour), and capped deficit fill.
16d23f8 to
52f70c9
Compare
Incident response — recommend priority merge
Bulk deployment triggers overloaded the Lagoon core with concurrent builds. Two independent flood sources found and staged:
1. Scheduled redeploys (
polydock:dispatch-scheduled-redeploys)next_redeploy_at IS NULL DESCordering that front-loads whole cohorts (adopted projects, newly-enabled store apps) the moment they become eligible.POLYDOCK_DEPLOY_MAX_PER_RUNdefault 10, schedule hourly, ordering most-outdated first (never-redeployed = oldest, then oldestlast_deployed_at). A backlog drains at ≤10/hour instead of spiking.2. Pre-warm refresh (
EnsureUnallocatedAppInstancesJob)POLYDOCK_PREWARM_BATCH(default 10), oldest instances first (the removal query already ordered bycreated_at). The creation loop is capped per app per pass — since in-progress instances count toward the pool, large deficits fill across passes instead of as one wall of builds.Both env knobs are tunable without deploys if 10/hour needs adjusting.
Test-pinned behavior (5 new tests)
never→ancient→recentuntouched)Testing
php artisan test— 454 passed./vendor/bin/phpstan analyse— no errors; Pint cleanGreptile Summary
This incident-response PR throttles two sources of Lagoon build bursts: scheduled redeploys are reduced from 50 every 10 minutes to a maximum of 10 per hour with oldest-first ordering, and pre-warm instance refreshes are now bounded by a shared global budget (one batch per hour, capped at
POLYDOCK_PREWARM_BATCH) dispatched in oldest-stale-first order across all store apps.DispatchScheduledRedeploysCommand): default cap dropped from 50 → 10, schedule changed from every-10-min → hourly, and ordering changed tolast_deployed_at IS NULL DESC, last_deployed_at ASCso adopted/newly-enabled instances drain gradually instead of spiking.EnsureUnallocatedAppInstancesJob):refreshStaleInstances()extracts the refresh logic into a single global-budget method with aCache::addhourly gate and oldest-app-first ordering; instance creation is also capped per-app-per-pass by the sameprewarm_batchconfig value.Confidence Score: 5/5
The change is safe to merge — it strictly reduces Lagoon build concurrency through a smaller default cap, an hourly schedule, and a cache-gated global refresh budget, all backed by five targeted tests.
Both throttle mechanisms are well-scoped: the scheduled-redeploy cap and ordering change are straightforward and directly tested; the
refreshStaleInstancesrefactor correctly gates behindCache::add, shares the budget across apps oldest-first, and the tests exercise the gate, the ordering, and the creation cap. No data is at risk; the worst-case outcome of a misconfiguration is slower pool replenishment, not data loss or additional build bursts.No files require special attention. The
config/polydock.phpcomment aboutprewarm_batchscope and the N+1 query pattern inrefreshStaleInstancesare worth a follow-up but do not block merging.Important Files Changed
refreshStaleInstances()with a global hourly cache gate and budget-shared oldest-first ordering; caps creation per-app viaprewarm_batch. Minor issues: config comment misleads on per-app vs global scope; N+1 DB queries per refreshable app (exists + min + count).prewarm_batchconfig key and updatesmax_per_rundefault to 10; the inline comment describes the value as 'per store app' which is accurate for creation but misleading for the refresh code path where it is a global cross-app budget.everyTenMinutes()tohourly()with explanatory comment;onOneServer()added for correctness on multi-server deployments.$this->travel()to verify the gate resets correctly.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[EnsureUnallocatedAppInstancesJob::handle] --> B[Load AVAILABLE apps needing maintenance] B --> C[refreshStaleInstances] C --> D{Any refreshable apps?} D -- No --> E[return 0] D -- Yes --> F{Cache::add prewarm-refresh-hourly-gate} F -- Already set within 1 hour --> E F -- Added new hour --> G[Sort apps by oldest stale instance created_at] G --> H[Iterate apps, share global budget=prewarm_batch] H --> I[queueUnallocatedInstancesForRemoval up to budget] I --> J[budget -= queued] J --> K{budget < 1?} K -- Yes --> L[break] K -- No --> H L --> M[Main loop: foreach app] E --> M M --> N{Excess unallocated?} N -- Yes --> O[queueUnallocatedInstancesForRemoval excess] N -- No --> P[app->refresh] O --> P P --> Q{needed > 0?} Q -- No --> R[continue] Q -- Yes --> S[needed = min needed prewarm_batch] S --> T{app supports_pre_warming?} T -- No --> R T -- Yes --> U[Create up to prewarm_batch PENDING_PRE_CREATE instances] U --> R%%{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"}}}%% flowchart TD A[EnsureUnallocatedAppInstancesJob::handle] --> B[Load AVAILABLE apps needing maintenance] B --> C[refreshStaleInstances] C --> D{Any refreshable apps?} D -- No --> E[return 0] D -- Yes --> F{Cache::add prewarm-refresh-hourly-gate} F -- Already set within 1 hour --> E F -- Added new hour --> G[Sort apps by oldest stale instance created_at] G --> H[Iterate apps, share global budget=prewarm_batch] H --> I[queueUnallocatedInstancesForRemoval up to budget] I --> J[budget -= queued] J --> K{budget < 1?} K -- Yes --> L[break] K -- No --> H L --> M[Main loop: foreach app] E --> M M --> N{Excess unallocated?} N -- Yes --> O[queueUnallocatedInstancesForRemoval excess] N -- No --> P[app->refresh] O --> P P --> Q{needed > 0?} Q -- No --> R[continue] Q -- Yes --> S[needed = min needed prewarm_batch] S --> T{app supports_pre_warming?} T -- No --> R T -- Yes --> U[Create up to prewarm_batch PENDING_PRE_CREATE instances] U --> RComments Outside Diff (1)
app/Jobs/EnsureUnallocatedAppInstancesJob.php, line 89-100 (link)When stale instances are queued for
PENDING_PRE_REMOVE, they are assigned auser_group_idand excluded fromunallocated_instances_count(which only countsuser_group_id IS NULLrows). After$app->refresh()on line 89, the deficit that drives$neededis therefore inflated by exactly the number of instances just queued for removal. In a pass where, say, 2 are refreshed and the target pool is 5,$neededcan become 3 (capped to 2), triggering 2 simultaneousPENDING_PRE_CREATEinstances while the 2 removal pipelines also eventually trigger full Lagoon deploys. The effective per-pass Lagoon pressure is up to2 × prewarm_batchrather thanprewarm_batch. This is bounded, but worth noting as an untested interaction — the existing refresh test does not assert on how many new instances are created in that same pass.Reviews (2): Last reviewed commit: "chore: throttle deployment bursts that o..." | Re-trigger Greptile