~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
151 lines · 15.8 KB Raw History
plan: "0016"
title: A tenant cluster's control plane is Ready in about one minute, not three
status: todo
queued: >
  Written by the lead 2026-10-13 for handoff (user: "write a comprehensive runbook for A+B and stage it
  for handoff"). Not in a running goal. One agent executes it end to end: worktree cp-fast, branch
  task/cp-fast, gate in CI, merge with the lead tool, release to prod, read the timing on prod.
source: >
  Prod reading 2026-10-13 (cluster goat-demo, gateway socialweb.computer, 4 cores, 7 GB): createCluster
  00:28:05Z -> status Ready 00:30:48.9Z = 2 min 44 s. Gateway log, strictly one member after another:
  postgres 19 s, openbao 14 s, front 13 s, kine 11 s, k3s 19 s, ccm 10 s, gap 12 s, eso webhook 13 s,
  eso 19 s, link-apiserver 13 s, link-openbao 16 s. Each "member placed" -> unit start takes 4-8 s
  because the HostProcess supervisor only acts on a 5 s ticker. The components themselves start in
  about one third of the total. Option A (start members as a dependency graph) and option B (event
  driven placement and readiness) change no design decision. Options C (pre-warmed pool) and D (shared
  postgres / OpenBao) are OUT of scope: D reverses the user's per-cluster isolation decision.
principle: >
  kcp is part of the application: members are HostProcess objects in the gateway's kcp, placed by the
  in-core HostProcess supervisor; keep that. Port 0 behind the edge: since bbb1e10c the cluster's front
  holds every member port (fd store, held ports in the front HostProcess annotation
  socialweb.computer/held-ports and status.listeners) - the front must stay FIRST, before any member that
  binds a held port. Fail fast, short timeouts: a member that fails ends the whole Spawn at once with
  its name and output; no new long waits. Proofs never on the hot path: timings are read from logs, not
  asserted inside product code.
state_rules:
  - Read /home/johnandersen777/.tmp/swc-lead/agent-rules.md first and follow it (live slots, CI gate via ci-wait.ts, ~/.tmp not /tmp, real names, no unit-test ratchets; integration tests under test/integration only).
  - Commit and push socialweb-computer-kcp only (GitLab origin). Never touch prod except in M3-PROD-READING, which the executing agent may do only with the lead's README "Release -> prod" flow.
  - status is one of todo, doing, done, blocked. Flip with `deno run -A ~/.tmp/swc-lead/land.ts status 0016 <status> <ITEM>`.
code_map:
  - "internal/clusterprovider/units.go:749 Spawn(ctx, key, steps) - writes the ControlPlane record, then `for i, st := range steps` runs each step IN ORDER (openbao via placeVault + st.Ready, every other via runStep). This loop is the sequential chain to replace (A)."
  - "internal/clusterprovider/units.go:806 runStep - member(...) adopt-if-verified, st.Prepare, memberSpec, place(...), gateAlive(st.Ready), verify(...), log 'member ready'."
  - "internal/clusterprovider/units.go:643 place - writes the HostProcess, then loops until Status.Runtime.InvocationID is set, sleeping unitPoll (units.go:54, 500 ms) up to PlaceTimeout. The 4-8 s per member is NOT this loop: it waits for the supervisor below."
  - "internal/clusterprovider/units.go:416 gateAlive - runs a member's readiness gate while checking the unit stays alive."
  - "internal/hostprocess/supervisor.go:33 DefaultInterval = 5 s; supervisor.go:426 Run - `for { s.Pass(ctx); <-ticker }`. A new HostProcess object waits up to one tick (B)."
  - "internal/hostprocess/supervisor.go:355 Pass - lists HostProcess objects and reconciles each (observe, apply, writeStatus)."
  - "internal/procgroup (procgroup.go): Component constants and StackOrder = [postgres, openbao, apiserver-front, kine, k3s-server, ccm, external-secrets-webhook, external-secrets]; the link components apiserver-link and openbao-link come after."
  - "internal/materialiser/materialiser.go:475 steps := r.opts.Steps(o, leases); :481 Host.Spawn(ctx, cluster, steps)."
  - "internal/clusterstack/clusterstack.go - builds each Step (Spec, Prepare, Ready). Each Step's Prepare/Spec reads what earlier members produced (ports from the front's holds, the vault address/token, kine's endpoint, k3s's kubeconfig): THESE READS ARE THE REAL DEPENDENCY EDGES."
  - "Prior art for B: commit 222001b4 'openbaoctl, web app: a node claim is written and taken up within a second' made the openbao controller wake on a kcp change with its interval kept as resync. Copy that pattern."
items:
  - id: M1-DEPENDENCY-GRAPH
    title: "A - members start as a dependency graph, independent members in parallel"
    depends_on: []
    decision: >
      Replace the in-order loop in Spawn with a small DAG runner: each step declares the members it needs
      (procgroup.Step gets a `Needs []Component` field, set in clusterstack where the step is built); a
      member starts as soon as every member it needs is ready; members with no unmet needs start at the
      same time (goroutines, one errgroup with the Spawn ctx). The first error cancels the rest and Spawn
      returns it with the member name (fail fast). The ControlPlane record and InvocationIDs are written
      exactly as today (cp.Members[i] keeps its index; guard writes with the existing h.lock(key)).
      Adoption (runStep's Verified path) and the openbao special case (placeVault) stay as they are,
      just run inside the graph. Expected graph (VERIFY every edge in clusterstack before trusting it;
      an edge exists when a Step's Prepare/Spec reads another member's output):
        apiserver-front: needs nothing (it holds the ports every other member binds) - first.
        embedded-postgres (store, EnsureStore): needs nothing.
        openbao: needs postgres (storeReady check), apiserver-front (held port).
        kine: needs postgres, apiserver-front.
        k3s-server: needs kine, apiserver-front (and openbao only if its Spec reads the vault - check).
        socialweb-cloud-controller-manager: needs k3s-server.
        external-secrets-webhook: needs k3s-server.
        external-secrets: needs k3s-server, external-secrets-webhook, openbao.
        apiserver-link: needs k3s-server, apiserver-front. openbao-link: needs openbao.
      Do not change any member's argv, ports, units or readiness gate in this item.
    steps:
      - "1. Read Spawn, runStep, place, gateAlive, verify (units.go) and every Step builder in internal/clusterstack/clusterstack.go. For each Step, list what its Prepare/Spec/Ready read from other members. Write the edge list into the plan item's notes in your report. STOP if a Step reads mutable shared state that is not another member's output (ask the lead)."
      - "2. Add `Needs []Component` to procgroup.Step; fill it in clusterstack for every step from your edge list. A step with an unknown Needs name is a build-time error (validate once in Spawn: unknown or cyclic -> return an error naming it)."
      - "3. In Spawn, replace the for loop with the DAG runner (errgroup.WithContext; a ready channel per member closed when that member is ready; each member goroutine waits on its Needs channels or ctx.Done()). Keep the storeReady check for kine/openbao. Keep log lines 'member placed' / 'member ready' unchanged (operators grep them)."
      - "4. Make concurrent writes safe: h.writeControlPlane and the cp struct are written under one mutex local to Spawn; verify() already retries conflicts per object."
      - "5. Build: `go build ./internal/... ./cmd/socialweb-computer && go vet ./internal/clusterprovider ./internal/procgroup ./internal/clusterstack`."
      - "6. Live check (take a live slot): `socialweb-computer dev up --instance cpf1 --profile market-network --bidders 1 --with-gateway --guest-image-source file:///var/lib/socialweb-computer/guest-image --vm-host-backend systemd-user` as test-0001; then read the gateway child's log: member placed/ready lines must overlap in time (postgres and front placed within 1 s of each other; ccm, eso webhook and apiserver-link placed within 1 s after k3s ready). Record createCluster->Ready for the bootstrap tenant (log line 'cluster provider: status' phase Ready minus first 'member placed'). dev down, release slot."
      - "7. Failure check: break one member on purpose (e.g. a bad kine path via the Step spec in a scratch commit you do not push) and confirm Spawn ends within a few seconds naming kine, and no other member is left placed (Reap / unit list empty for that cluster after dev down)."
    done_when: "Members start as a graph; the bootstrap tenant on a quiet market network (load under 30) is Ready in about 1:30 or less; a broken member fails the Spawn fast with its name; TestTenantClusterScalesFromZeroOnMarketVMsAndBack passes on the branch."
    status: done
  - id: M2b-CLUSTERS-AND-PHASES
    title: "Clusters start in parallel, Initializing is reported at once, NodeClaims reach Running"
    depends_on: [M1-DEPENDENCY-GRAPH]
    decision: >
      Found by the subscribeClusters agent 2026-10-13 (live, market network cs1): (1) the gateway brings up ONE
      cluster at a time - a new cluster waits behind the bootstrap tenant; each cluster's Spawn must run in its
      own goroutine (per-cluster lock h.lock(key) already exists; the provider pass must not block on one
      cluster's Spawn). (2) The cluster phase stays Unavailable while members start, because Initializing is
      written only after every member is placed; write Initializing when the first member is placed (subscribers
      see progress; `#member` events already exist). (3) A NodeClaim stays Joining and never reaches Running even
      with its node Ready and pods running (market network; check prod too); the web app node market / cluster
      provider must set Running when the node is Ready (find who owns that transition:
      socialweb-computer-k8s/lib/socialweb-computer-k8s-node-market and internal/providermain/nodes.go confirmHeld).
      (4) subscribeClusters answers ClusterNotFound for an account with no clusters; it should send an empty
      snapshot + #info SnapshotComplete instead (a new user subscribes before createCluster).
    steps:
      - "1. Find where the provider pass calls Spawn per cluster; run each cluster's Spawn without blocking the others (bounded concurrency, e.g. 4); keep h.lock(key)."
      - "2. Write status Initializing at the first member placed; keep Ready semantics unchanged."
      - "3. Make NodeClaim go Joining -> Running when its Node is Ready; the existing integration tests that accept Joining must then require Running (scale_to_zero_test.go, subscribe_clusters_test.go)."
      - "4. subscribeClusters: no clusters -> empty snapshot, not ClusterNotFound (keep ClusterNotFound for an unknown ?name=)."
      - "5. Live check: create two clusters at once on a market network; both reach Ready about together; subscribe shows Initializing early and the claim reaching Running."
    done_when: "two clusters created together are Ready at about the same time; Initializing appears with the first member; NodeClaims reach Running; integration tests updated to require Running pass."
    status: done
  - id: M2-EVENT-DRIVEN-PLACEMENT
    title: "B - the HostProcess supervisor wakes on a kcp change; placement and readiness stop waiting on tickers"
    depends_on: [M1-DEPENDENCY-GRAPH]
    decision: >
      The supervisor (internal/hostprocess/supervisor.go Run) watches HostProcess objects in its
      workspace (dynamic client Watch on GVR, label/prefix filter as Pass uses) and runs Pass on every
      add/update (debounced: coalesce events within 100 ms), keeping DefaultInterval as a resync. On a
      watch error it falls back to the ticker and re-establishes the watch with a short back-off (fail
      fast: log once per failure, never block). place() in units.go waits for Status.Runtime with a watch
      on that one object instead of the 500 ms unitPoll (keep unitPoll as the fallback when the watch
      fails). Readiness gates in clusterstack (st.Ready) that poll a file or endpoint must poll at 200 ms
      or less and return at once on a terminal sign (unit exited) - check each gate's interval and lower
      it if larger. Do not change what a gate checks.
    steps:
      - "1. Read 222001b4 (git show 222001b4) and copy its watch + resync pattern."
      - "2. supervisor.go Run: start a watch goroutine feeding a buffered wake channel; select on ctx.Done, ticker, wake. Pass must stay safe to run back to back (it is idempotent today; keep it so)."
      - "3. units.go place: watch the HostProcess by name (fieldSelector metadata.name) until Status.Runtime.InvocationID is set or PlaceTimeout; fall back to the unitPoll loop on a watch error."
      - "4. clusterstack readiness gates: list each gate's poll interval; lower any above 200 ms; make sure each gate returns on ctx cancel (Spawn's errgroup cancels siblings on the first failure)."
      - "5. Build as M1 step 5 plus `go vet ./internal/hostprocess`."
      - "6. Live check as M1 step 6. Expect 'member placed' -> 'placed' (supervisor) under 1 s per member. Record createCluster->Ready; target about 1 minute or less on a quiet host. Also record one VM placement (ComputeVM -> VM Running) and one DenoPod restart (dev restart <inst> <denopod>) before/after, since they use the same supervisor."
    done_when: "Placement latency under 1 s per member; bootstrap tenant Ready in about 1 minute on a quiet market network; supervisor survives a kcp restart (dev roll kcp) and keeps placing (watch re-established, logged once); integration tests below pass."
    status: done
  - id: M3-PROD-READING
    title: Release, deploy, and read the new timing on prod
    depends_on: [M1-DEPENDENCY-GRAPH, M2b-CLUSTERS-AND-PHASES, M2-EVENT-DRIVEN-PLACEMENT]
    decision: >
      After merge, release and deploy with README.md "Release -> prod" (gateway deploy.sh, bidder
      upgrade), then time a fresh cluster with the README "Cluster XRPC with goat" flow (createCluster
      name=cpf-<date>, wait for listClusters phase Ready, then controlPlaneDown and deleteCluster).
      Update the README "Expected timing" table with the new numbers and the per-member breakdown from
      the gateway log. Then run the prod finish line (README step 5) to confirm scale from zero still
      works (~60 s) and back to zero.
    steps:
      - "1. `deno run -A ~/.tmp/swc-lead/ci-wait.ts <head>` green, then `deno run -A ~/.tmp/swc-lead/merge.ts merge task/cp-fast` (it reuses the CI result) and `merge.ts rm task/cp-fast`."
      - "2. Release + deploy per README. If the web app or bidder is not Ready after the upgrade, run `socialweb-computer rollback` as the account and STOP (report to the user)."
      - "3. Time createCluster -> Ready on prod; read the member lines (`journalctl _UID=$(id -u socialweb) -o cat | grep -E 'member (placed|ready)|status'`); put the numbers in README; commit and push."
      - "4. Finish line up/down (README step 5); delete the timing cluster."
    done_when: "Prod createCluster -> Ready reading recorded in README; finish line passes; plan items flipped done."
    status: done
acceptance:
  checks:
    - id: ACC16-TIMING
      text: "On prod, createCluster -> Ready is about 1 minute (was 2 min 44 s on 2026-10-13), with overlapping member placement visible in the gateway log."
      status: done
    - id: ACC16-NO-REGRESSION
      text: "TestRequestSecretsReachTheGuestThroughTheRFPFlow and TestTenantClusterScalesFromZeroOnMarketVMsAndBack pass on the merged commit; prod finish line passes; a broken member fails fast with its name."
      status: done
integration_tests: >
  go test -tags integration -count=1 -v -timeout 60m ./test/integration/ -run 'TestTenantClusterScalesFromZeroOnMarketVMsAndBack|TestRequestSecretsReachTheGuestThroughTheRFPFlow'
  (take live slots; host load under 30; a timeout at high load proves nothing).
stop_and_ask:
  - "An edge you cannot prove from the code (a member that might need another but nothing reads it)."
  - "Any change to member argv, ports, held-port logic, store or vault layout."
  - "A test failure you cannot explain by your change after one rerun on a quiet host."
  - "Prod not Ready after deploy (roll back first, then ask)."