Repository navigation
Deploy each service independently in static environments - #258
Merged
Merged
Conversation
An environment was reconciled by one job that owned one generation counter and one run token for every service. A merge to service B raised the counter, so the run for service A lost its authority: its build, its after-build step and its rollout were discarded and the next pass redid them. In a static environment with hundreds of services, a steady flow of merges kept the slowest services from ever landing. A configuration error in one service marked the whole generation observed, so services that had not run were recorded as done. Every deploy row now carries its own desired and observed generation (migration 037). Each accepted change is one intent and one queue job, and a job claims only the rows its intent selects. A run owns a row only while the row carries the run token at the run generation, is not yet observed, and the environment is not blocked. A newer accept re-stamps the rows of its own scope, so the older run of that scope stops without publishing, while runs for other services continue. Accepted push SHAs are floors for full passes. Prerequisites order services inside one run only; a service never waits for, or fails because of, a run of another service. The environment status is derived from the rows. A root configuration error is recorded on the environment by any intent and cleared by the next import that succeeds. A service with a broken lifecycle.yaml fails alone and blocks only its dependents. Intents finish and move the environment watermark in one transaction under the Build row lock, and the sweep repairs a stale watermark instead of replaying a finished generation. A teardown closes the work accepted before it and keeps a deploy request accepted during it. Every renewable deployment lock uses a 60-second lease renewed every 20 seconds, so a worker that dies frees its locks within a minute. Before a service is applied, the run waits for an earlier deploy job of that service to be terminal with its pods stopped, with a 45-minute limit. A lost promotion lease lets launched applies settle before the queue retries the run. An Aurora restore keeps an endpoint it cannot verify and restores again only when the database is confirmed gone. Migration 037 marks intents at or below the old watermark as finished, so nothing replays after the upgrade.
vmelikyan
force-pushed
the
fix/service-independent-reconciliation
branch
from
October 11, 2026 20:02
292d0b6 to
715ee08
Compare
Cover the production paths the simulator stubs or that no unit test reached: - a broken service lifecycle.yaml in the real import fails that service alone; any other import error still fails the import - a rollout error of a current run is recorded on its rows in the same attempt; after the run lost its rows it is dropped - the environment-level authority predicate: blocked environments, owned rows, unfinished accepted intents, and the token-only legacy path - the queue failure policy: an escaping configuration error finishes the intent, an error after supersession is ignored, transient errors retry until the final attempt, a lost lock rethrows, and an environment-wide claim that fails leaves the generation pending - a replaced or finished generation at claim time - the accepted SHA stays the floor when GitHub lookups fail - the derived environment status: teardown and config-error guards, pending before failed, inactive rows ignored, unchanged status not rewritten - a CLI deploy that reports failure, the Codefresh deploying fence, and the ingress job clearing a stale failure note only after a clean apply
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.
Problem
Lifecycle reconciles an environment with one job for the whole environment. One generation counter and one run token control all services. A merge to service B raises the counter. The run for service A reads the new counter and loses its authority: its build, its after-build step and its rollout are discarded, and the next pass must do that work again. In a static environment with hundreds of services and a steady flow of merges, the pass restarts before the slowest services land. Those services do not deploy for a long time.
The same design marks work as complete when the work did not run. When the configuration of one service is invalid, the job records the error for the environment and marks the whole generation complete. Every merge that the pass had collected is consumed. Service A stays at its old version and no job returns to it.
Solution
Each
deploysrow carries its own desired generation and observed generation. Each accepted change is one intent, and each intent is one job. The job claims only the rows that belong to its intent. A run owns a row only when three conditions are true: the row carries the run token, the row is at the run generation, and the environment is not blocked. The rule does not refer to other services.lifecycle.yamlfails alone, with the parse error on its row. Its dependents are not deployed. All other services continue. A failed service that has no row yet is kept on the intent and shown in the environment status as "Not imported" until a later import clears it.Schema: migration 037 adds
desiredGenerationandobservedGenerationtodeploysand a partial index on pending rows. It marks each accepted intent at or below the old environment watermark as finished, so the new worker does not run intents the old worker already completed. There is no new table. The migration is safe to run again.Upgrade
Scale the
workerdeployment to zero before the rollout and up again after the new version starts. An old worker takes a promotion lock for the full environment and writes the watermark with the old rules, so it must not run on the new schema. Oldwebpods are safe during their own rollout: their accepts do not stamp rows, and the new worker adopts the rows of a claimed intent at claim time. The worst case is one extra full pass for an environment-wide intent accepted in that window.Verification
Automated
pnpm test:coverage, the CI gate: 502 suites, 9811 tests pass (11 skipped). Coverage is 98.10% statements, 91.78% branches and 98.12% functions. The thresholds are 98, 91 and 98. Every statement this change adds is covered, except three log lines.serviceReconciliation.test.tsruns the real reconciliation path on a simulated clock. Only the image build, the apply, the YAML import and the queue are simulated. The queue has worker slots and keeps one job per id, as BullMQ does. Transactions run one at a time, as the Build row lock makes them. The scenarios include: a merge to B while A builds, a newer merge to A, a config-repo push during a run, a service config error, a root config error on a service push, a transient failure with retry, a lost signal recovered by the sweep, a retry after a successful rollout, a service that moves to another repository, a config error that lands during a rollout, and dependents that build while their prerequisite runs elsewhere with only two worker slots.deploymentManager.test.tscovers per-service ownership checks, the wait for an earlier deploy job and its 45-minute limit, a failed ownership check before a launch, scheduling by prerequisite inside a run, the drain after a lost lease, supersession at admission and the Codefresh submission gate.tsc --project tsconfig.server.json --noEmit, ESLint and Prettier: clean.Live test on the local stack
Stack:
mise prd(kind, Tilt, buildkit, local registry), webhooks through ngrok. Static environmentancient-glitter-149521fromvmelikyan/lc-test-2branchstatic. It trackslc-test-gh-type(lc-test),lc-test-3(lc-test-3),lc-test-4(lc-test-4) and a Helm service onmain. Two Dockerfiles have a 120-second step, so builds overlap.lifecycle.yamlin B, then a fixAfter every scenario the environment reported the expected final status with all generations observed, and the sweep sent no duplicate or stale signals.
Not tested live
Follow-ups