Skip to content

Pool image/version rollout: rolling replacement of warm VMs - #85

Open
richardcase wants to merge 7 commits into
mainfrom
richardcase/pool-image-version-rollout-rolling-replacement-o
Open

richardcase wants to merge 7 commits into
mainfrom
richardcase/pool-image-version-rollout-rolling-replacement-o

Conversation

@richardcase

Copy link
Copy Markdown
Member

Summary

Closes #74. When a pool's microvm_template (image/kernel/rootfs spec) changes via UpdatePool, this adds an automatic, budget-controlled rolling-replacement mechanism: stale warm VMs are proactively swapped out for VMs built from the new template, without ever touching a leased VM, and without dropping the pool below its configured availability budget.

  • Proto: PoolSpec.TemplateHash (server-computed), PoolSpec.RolloutPolicy (max_unavailable as count or percent), VMRecord.TemplateHash, PoolStatus.StaleCount, new rollout-related EventType values.
  • internal/api: server-side hash computation (never trusts client input), stale_count reporting, POOL_ROLLOUT_STARTED emission.
  • internal/reconciler: new RolloutController, one per pool, deletes budgeted stale VMs; backfill is free via the existing Strategy/reconciler wiring. Includes a fix for a TOCTOU race that could otherwise delete a freshly-leased VM, and a fix so the configured budget genuinely bounds unavailability (accounting for in-flight replacements, not just in-flight deletions).
  • internal/store: persists template_hash and rollout_policy across restarts.
  • internal/poolmgrctl: surfaces stale_count in pool get/pool list; rollout_policy is settable via the existing spec-file mechanism.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./... (full module, including race-detector coverage on the reconciler/store packages)
  • Per-task and final whole-branch code review completed

Add PoolSpec.template_hash (12) and rollout_policy (13, new RolloutPolicy
message), VMRecord.template_hash (9), PoolStatus.stale_count (5), and
EventType values VM_DELETED_FOR_ROLLOUT (11), POOL_ROLLOUT_STARTED (12),
POOL_ROLLOUT_COMPLETED (13). Regenerated via buf generate. Fields are
additive and unused; no other Go code changes.
CreatePool/UpdatePool now compute PoolSpec.template_hash server-side from
microvm_template (protojson.Marshal is used instead of proto.Marshal
because the template contains map fields, and protojson sorts map keys
for deterministic output). UpdatePool ignores any client-supplied
template_hash and emits POOL_ROLLOUT_STARTED when the hash changes and
the resulting stale VM count is nonzero. GetPool/ListPools report
PoolStatus.stale_count by comparing each VMRecord's template_hash
against the pool's current one, and Provisioner.Provision copies the
pool's current template_hash onto newly created VMRecords.

Also add the template_hash column to the pools/vms tables and thread it
through the sqlite store's convert/query code, since neither existed
before this change and the feature can't round-trip without it.
Add store.UpdateVMPhaseIfCurrent and reconciler.EnsureVMDeletedIfPhase so
RolloutController's deletion path is guarded against a VM being concurrently
claimed between its ListVMsByPool snapshot and the delete call, mirroring
ClaimAvailableVM's own guarded-UPDATE pattern. Also clamp resolveBatchSize
to the pool's size, honor an explicit rollout_policy.count of 0 as an
intentional pause instead of coercing it to 1, and document the known
event-mislabeling limitation when Sweeper.retryPendingDeletions finishes a
previously-failed rollout deletion.
…I plumbing

pool get/list table output now includes a STALE column sourced from
PoolStatus.stale_count (JSON output already covered it automatically).
rollout_policy needed no new CLI plumbing: pool create/update already
pass an arbitrary PoolSpec loaded from --spec-file straight through, and
RolloutPolicy is just another field on that generated message.
CreatePool/UpdatePool passed RolloutPolicy through in-memory, but the
SQLite schema/row mapping (extended in a prior change for
template_hash) never gained a column for it, so GetPool/ListPools and
any poolmgrd restart reloading pools from the store silently dropped
it, reverting to the reconciler's default batch size of 1 with no
error.

Store it as protojson in a nullable TEXT column so an unset policy
round-trips as nil rather than an empty RolloutPolicy{}, matching the
reconciler's unset-vs-explicit-zero distinction in resolveBatchSize.
Count current-hash VMs still PROVISIONING/CREATE_HOOK_RUNNING (a
rollout's own in-progress replacements) as unavailable load alongside
in-flight DELETING VMs when computing RolloutController's per-tick
budget, so max_unavailable actually bounds pool unavailability instead
of only accounting for VMs still mid-deletion.

Add rollout test coverage for the notifier-driven backfill path (the
"replacement" half of rolling replacement), and add a
newRolloutController override with a fake in poolmanager tests,
mirroring newReconciler/withFakeReconciler, so tests that pass a nil
store no longer risk a real RolloutController.Tick panicking on it.
Copilot AI lite review requested due to automatic review settings September 16, 2026 09:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@richardcase richardcase left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found three correctness issues in the rollout and upgrade paths. The existing tests in internal/{reconciler,store,api,poolmanager,poolmgrctl} pass; an additional two-tick budget reproduction fails, and applying the new schema over the main-branch schema reproduces the missing-column error.

Comment thread internal/store/schema.sql
hook_failure_policy INTEGER NOT NULL,
heartbeat_interval_ns INTEGER NOT NULL,
heartbeat_expiry_threshold_ns INTEGER NOT NULL,
template_hash TEXT NOT NULL DEFAULT '',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Migrate existing databases before querying the new columns

store.Open only executes this schema, and CREATE TABLE IF NOT EXISTS does not add columns to existing tables. Opening a database created by the current main branch therefore leaves both tables unchanged, while all pool/VM queries now require the new columns. Applying the two schemas in sequence reproduces no such column: template_hash; daemon startup's pool loading and API operations consequently fail after an upgrade. Please add an idempotent migration for pools.template_hash, pools.rollout_policy, and vms.template_hash, with an upgrade test using the old schema.

if err != nil {
continue // left DELETING; retried by Sweeper.retryPendingDeletions or a later Tick
}
FinishVMDeletion(ctx, c.store, c.pool, vm, c.notifier, c.metrics, poolmgrv1alpha1.EventType_VM_DELETED_FOR_ROLLOUT)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Provision rollout replacements independently of the lease strategy

This notification does not guarantee a replacement: immediateOnLease.OnVMDeleted and DesiredNewVMs both return zero. Updating the template of a warm IMMEDIATE_ON_LEASE pool therefore deletes its available VMs over successive ticks without provisioning any new ones, eventually leaving an empty pool that cannot receive claims to trigger replenishment. MIN_SIZE_THRESHOLD also defers replacements until availability falls below its threshold. Please give rollout deletions a replacement path that works for every supported strategy and add integration coverage with the real reconciler.

c.rollingOut = true
}

budget := resolveBatchSize(c.pool.GetRolloutPolicy(), c.pool.GetSize()) - inFlight - unavailableProvisioning

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep missing replacements charged against the availability budget

Once a stale VM's row is deleted, its budget slot is forgotten until a replacement's PROVISIONING row appears. Provision persists that row only after CreateMicroVM returns, so a slow/failed create (or a replacement later quarantined/deleted) allows the next rollout tick to delete another healthy VM despite the previous replacement never becoming available. A two-tick reproduction with size=3, default max_unavailable=1, and the first replacement still pending leaves only one VM. Please account for missing/failed replacements or the actual availability deficit, rather than only DELETING and current-hash provisioning rows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pool image/version rollout: rolling replacement of warm VMs

2 participants