Repository navigation
feat(deployments): run worker and scheduler roles beside the web container - #1232
Conversation
Roles (oblien#935) were only declarations so far: nothing stored them. This adds the storage, API and snapshot layer, following releaseCommands everywhere it appears. Nothing starts a container yet. project.roles is nullable jsonb: NULL means web only (every existing project), [] means explicitly no roles. deployment.role_containers is added now for the later fan-out, teardown and pause/resume commits; nothing reads or writes it here. Migration 0189 adds both columns. roles is accepted on project create, ensure, setOptions, the scan and project schemas, and the SDK source workflow. The server runs any non-null value through parseOpenshipConfig, so the API enforces the same rules as openship.json and stores the parsed result without the preset-only when/unless fields. Omitted leaves the value unchanged (NULL on create); null and [] are stored as given. The openship.json overlay in the prepare service carries roles, including a declared []. Each deployment's config snapshot freezes roles only when the project has a non-empty list, so projects without roles snapshot exactly as before. Two rules are tightened in the parser because the runtime will depend on them: role names must be DNS labels (they end up in container names and labels), and replicas is capped at 100 like app scaling.
Add a "roles" runtime capability and an optional deployRole() on the runtime adapter. DockerRuntime implements it: one container per role replica, from the deployment's image, with the web container's env, mounts, restart policy, resource caps and project network, but no port, no network alias and no openship.service label. The role's stop signal and grace period are set on the container. A beforeStart hook lets the caller join connected networks before the process starts. A container whose hook or start fails is removed. Containers carry a new openship.role label. CloudDockerRuntime drops "roles" from its inherited capability set, and BareRuntime does not declare it. Nothing in the deploy pipeline calls deployRole yet; wiring it in and the stop, destroy and listing paths for role containers come next. Refs oblien#935
Add a runtime-agnostic role rollout module that decides the order in which a deployment's worker and scheduler containers are started, checked, handed over, kept or unwound. Scalable replicas start beside the previous ones and are verified before any singleton moves; singletons hand over stop-first with the old container retained, and a failure destroys what was started before restarting what was retained. Every side effect is injected and teardown is bounded and best-effort, so it is covered with fakes. Nothing calls it yet: wiring it into the deploy pipeline comes next. Refs oblien#935
A single-app Docker deploy now starts its worker and scheduler containers, records them on the deployment, and retires the previous deployment's. - Refuse roles before the build on multi-service deploys, static sites and runtimes without the roles capability, so a refused deploy runs no build, release command or container. - Roll roles out after the leftover-container reap and before the web pipeline, so the old web keeps serving while roles roll. The ids are recorded before anything web-facing changes. - Commit (retire the previous roles) once the web is live and routed, abort on a failed or cancelled deploy. A cancel is also checked before the old singleton is stopped and after each verify. - Stop the previous singleton tolerantly: an already stopped or removed container counts as stopped. Teardown bounds include each role's grace period. Compose, rollback, pause/resume, teardown and cleanup paths are not wired yet and come next. Refs oblien#935
Role containers now follow their release through the lifecycle paths outside the deploy. Pause, resume and restart go through deploymentContainerIds, which lists the web container first and then the role containers. Delete, reject and build-cancel remove them via the cleanup manifest, and the cleanup keep set protects a live release's role containers. Rollout teardown is concurrent: commit destroys every previous container at once, and the unwind destroys every started container before restarting every retained one at once, so an old and a new singleton still never overlap. The teardown bound now also covers the previous roles' grace periods. A compose deploy that follows a single-app deploy retires the predecessor's role containers once it succeeds. Refs oblien#935
…hem on Docker A project that declares roles is refused for a server move or copy before any run exists, because the migration pipeline does not carry role containers across hosts. The refusal sits after the control plane and not-server-hosted checks and applies to every intent. openship.json reference gains a Runtime roles section: fields and validation rules, where roles run, what a role container gets, deploy order, the startup check, signals and lifecycle. A real-Docker end-to-end test drives the rollout through first deploy, worker overlap, stop-first scheduler handover, a crashing worker, a crashing scheduler and removing every role. Refs oblien#935
The error-boundary gate requires every catch to report, rethrow or document its error. Refs oblien#935
|
perfect, amazing work thank you |
sudanese
left a comment
There was a problem hiding this comment.
Thanks for this. I tested 7b2220ec6 locally with Laravel 12.70.0 on Docker Desktop. The app used a custom PHP CLI Dockerfile, a database queue worker with a 90s grace, a scheduler, and persisted SQLite. The app used the portless deployment path, so this did not exercise edge routing. The verification commands I ran passed.
The core path held up well. Deploys, env changes, rollback and the lifecycle actions all carried the roles. A failing release or exec health command kept the previous version running, and a long queue job drained cleanly within its grace. Below are the gaps I found, two reproduced and the rest from reading the code, each with a suggested fix. Happy to be told any are already planned.
Reproduced
1. An interrupted rollout leaves role containers running, and the next deploy runs two schedulers.
role_containers is written only after rolloutRoles returns (build-pipeline.ts:2507). On boot, sweepStaleInFlight (apps/api/src/app.ts:422) only marks the deployment cancelled.
Repro: deploy H (queue + scheduler) → start deploy I → kill the API once I's scheduler has started → restart the API. I is cancelled, but its queue and scheduler keep running unrecorded, and H's scheduler stays stopped. Deploy J then starts its own scheduler beside I's orphan, and two minute ticks each fired twice. The orphan worker also kept taking jobs with I's code.
Suggested fix: durable handover recovery needs to track container ownership and activation progress. Label-based discovery could provide an interim guard. But a deployment that differs from the active pointer may be reconciling, intentionally preserved on cancellation, or already serving before the pointer was updated. Such a guard needs an explicit reconciliation policy, and it is not a false-positive-free orphan check. Until recovery exists, the docs should explain how interrupted handovers can require reconciliation before redeploying.
2. Renaming a singleton runs old and new together.
Previous singletons are matched by role name (role-rollout.ts:208). After renaming scheduler to cron, the old scheduler wasn't stopped first and was only removed at commit(). That gave a 23.3s overlap, and that minute's tick fired twice.
Suggested fix: build a handover plan from both deployments' frozen role definitions. Stop and retain outgoing singletons before any replacement that could conflict with them starts, including renames and changes to a scalable role. Handle removal when there are no new singletons. On failure, restore an old singleton only after all potentially conflicting replacements are confirmed removed or missing, even when their names differ. This needs coordinated startup and unwind changes.
From reading the code (verified at 7b2220ec6)
unwindrestarts the old scheduler even if removing the new one failed (role-rollout.ts:153-178). Suggest restoring a predecessor only after every potentially conflicting replacement, including renamed roles, is removed or confirmed missing. If that is uncertain, leave the predecessor stopped and report the container IDs. Two tests (role-rollout.test.ts:548,:609) currently assert the restart.stopPrevioustreats any status other than"running"as stopped (role-runtime.ts:145-160). The presentation status maps running-but-unhealthy tofailed, restarting todeployingand paused tostopped. Suggest using the rawsampleStabilitystate and accepting onlycreated,exited,deadormissing.- Startup check (
role-runtime.ts:105-121):completed(exit 0) counts as ok, and a replica whose inspection fails is left out of the findings. Under restart policynooron-failure, a role that exits 0 can pass this check. If the rest of the deploy succeeds, the old scheduler is retired. Suggest requiring exactly onestablefinding per container ID for roles. - Inherited healthcheck (
docker.tsdeployRole):Cmdis replaced butHealthcheckisn't. An inherited HTTP healthcheck can mark a worker unhealthy if it probes the web server the role no longer starts. SuggestHealthcheck: { Test: ["NONE"] }on role containers. - Move/copy guard (
project-move.ts:166-174): onlyproject.rolesis checked, soroles: []lifts the guard before the redeploy that retires the running roles. Suggest checking the active deployment's frozen roles and container refs, plus unresolved role work, at both move/copy entry points. That includes reconciling and intentionally preserved cancelled deployments. PORT(docker.ts:1128):portlessdrops only the generatedPORT, so a user-setPORTstill reaches every role. Suggest filteringPORT=...and appending a bare"PORT"entry indeployRole, leaving other callers of the shared environment helper unchanged.- Failed retirement:
commit()removes the previous roles best-effort, and retention cleanup doesn't touch role containers. A failed removal may leave an old worker consuming jobs with the previous release's code. Suggest at least surfacing it in the deploy's existingwarningMessage, naming the container.
Laravel-specific
- In the tested Laravel 12.70.0 image,
schedule:workas PID 1 did not exit on SIGTERM. It kept ticking until SIGKILL at the grace deadline. With 90s configured, scheduler shutdown took about 90s. The docs should distinguish signal delivery viaexecfrom whether the command handles that signal. They should also explain that scheduler grace depends on the command and version. (The 90s comes from the Laravel presets I contributed, and a short scheduler grace is probably the better preset value on 12.x.) when/unlesscopied from a preset intoopenship.jsonare dropped without a warning (the API rejects them with a 400). Copying both preset entries makes both roles unconditional, andhorizonmay instead fail if its package is absent. A parser warning would catch that.
Docs (openship-json.mdx)
:444suggested wording: "For a normal same-name handover, the recorded predecessor is stopped before its replacement starts. Runtime failures or an interrupted handover can leave containers unresolved. Reconcile them, and restart the scheduler if needed, before redeploying. This does not guarantee exactly-once scheduled execution." Still worth recommending application-level guards such asonOneServer()/withoutOverlapping().:402stopSignal: when it is omitted, the image'sSTOPSIGNALapplies, and SIGTERM only when the image sets none.:403stopGracePeriod: "the daemon's default" is more accurate than "Docker's 10 seconds". An unset value was killed after about 3s here.:447-453: worth saying that a role that dies after the deploy isn't detected or shown yet. A killed worker stayed down for minutes while the deployment showed ready.
Suggested priorities: the reproduced rename issue, coordinated unwind/stop/startup fixes, and accurate singleton documentation. Label-based admission checks need further design around reconciliation and preserved workloads.
|
@sudanese thank you for testing this on a real app, and for turning most of the findings into fixes the same day (#1262, #1263, #1264, #1265, #1266). I re-read main against your list: the role healthcheck, What I see left:
Are you already on any of these? If not, I'll start with 2. |
Renumbers the migration to 0195_external_project. Main took 0192 for runtime_roles (oblien#1232) and now ends at 0194_managed_ingress. Four conflicts, all additive: - meta/_journal.json: keep main's 0192 to 0194 entries and append external_project as 195. - migrate-chain.test.ts: the fresh-apply expectation gains main's roles column beside external_config. - project-move.ts: keep both imports, findActiveDeployment from main and assertNotExternal from this branch. - mcp-tools.mdx: regenerated rather than hand-merged, so the tool count is derived. 507, main's 505 plus this branch's 2 routes. One silent break the merge did not flag: main's "adds runtime roles after the applied lifecycle migration" test migrates to 0192 and then to HEAD, expecting the project row to gain exactly roles. 0195 also adds external_config, so that expectation needed it too. The 12 migrate-chain tests pass with it and failed without it.
Run explicitly declared worker and scheduler processes as separate containers from the web app's built image. A Laravel queue worker or Rails Sidekiq process can roll out with its release and follow it through pause, resume, restart, rollback and deletion.
Part of #935: this implements the deployment and runtime support. Automatic preset roles are separate work.
Behavior
openship.jsonor the project API, validate them through the shared parser, and freeze them on the deployment.PORT, volumes, restart policy, resource limits and connected networks, with their own command and stop settings. They publish no ports or web network aliases. Disable the image's inherited web healthcheck; roles get process stabilization and an optional exec startup check.Migration
0192_runtime_rolesadds nullableproject.rolesanddeployment.role_containerscolumns after the existing lifecycle migration. Existing projects retain their behavior. Contracts, SDK, dashboard configuration plumbing and the configuration reference carry the new field.Supported scope
Single-app Docker deployments. Cloud, bare, Kubernetes, static sites and multi-service deployments refuse configured roles before building. This does not add a role list/logs UI, steady-state role health monitoring or automatic role detection.
If replacement removal cannot be confirmed during rollback, recovery requires removing those replacements before restarting the previous singleton. A timeout is not treated as proof of shutdown.
Validation
ee70abfc2d7cc932f5a08ea734b489bcd3653bf5, including both API shards, database, adapters, SDK/CLI, Cloud Docker workspaces and Linux/macOS Actions execution: https://github.com/oblien/openship/actions/runs/38698871550.