Skip to content

os migrate plan/apply never exit once a host config is composed — the process outlives its own "Graceful shutdown complete" (78 min, measured) #13027

Description

@hotlong

Follow-up to objectstack#12938 / PR objectstack#12952, found while running the
consumer acceptance that PR named (objectstack-ai/cloud#1710).

Measurement

ObjectStack Cloud's migrate-control-db.yml, plane=staging, apply=false, against
the real Neon staging control plane, framework pinned at 15d55fb2430f (carries
fc8a33935). Run: objectstack-ai/cloud actions run 33182129326.

The command is os migrate plan inside docker run --rm, cwd
/repo/cloud/apps/cloud (an objectstack.config.ts is present, so the host
stack is composed). The CLI finished and said so:

15:03:52.522   17 change(s): 0 safe, 0 needs-confirm, 17 destructive
15:03:52.522   Apply with: os migrate apply (add --allow-destructive for drops / tightenings)
15:03:52.522   4316ms
15:03:52.517 INFO Graceful shutdown started
15:03:52.555 INFO OK Graceful shutdown complete

Then nothing. The next line in the log is 16:22:40 The operation was canceled. — the run was cancelled by hand after 78 minutes. The shell step's
very next statement is an echo, and it never printed, so the shell was still
blocked on that one docker run: the process printed its own graceful-shutdown
line and then did not exit.

Elapsed inside the CLI was 4.3 seconds. Everything after that is a process that
will not die.

Why this is a fix-here defect, not a workflow defect

The composition objectstack#12952 introduced runs host plugins for their
DECLARATIONS: init() runs, start() is replaced with a no-op, destroy() is
forwarded. A host plugin that acquires a resource in init() — a pg pool, an
interval, a socket, a watcher — and releases it from something registered inside
start() now has no release path at all, because the thing that would have
registered the release never ran. The event loop stays alive; process.exit is
never reached; the kernel has already reported a clean shutdown, so nothing looks
wrong.

That is a direct consequence of the declaration-only composition and it is
invisible to the CLI's own test suite, which does not assert that the process
exits. The measured shape — 36 host plugins composed, clean shutdown logged,
process alive 78 minutes later — is the first real deployment this composition
has been run against.

Consequence

os migrate plan and os migrate apply never return on a project with a host
config. For cloud's operator workflow that means the job hangs until the job
timeout (six hours by default) after the plan has already been computed and
printed, and any consumer that reads the --json payload — cloud's coverage gate
is one — never gets to run at all.

Suggested shape

Two candidates, not adjudicated here:

  • Make the declaration-only composition symmetric: if start() is suppressed,
    the composition owes the teardown its suppression removed. destroy() is
    already forwarded, so the seam exists.
  • Have plan / apply exit deliberately once the document is written, rather
    than relying on the event loop draining. That is a smaller change and it also
    covers host code this repo cannot audit, which is the same argument
    objectstack#12952 used for declaration-only composition in the first place.

Filed without an assignee.

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions