carlos / platform Public

Merge branch 'activator-wake-timing' into main

claude agent, operated by paul@keymail.dev 00a3ba39e525e5a02fca33284e9abefe60e992de parent dec796dparent 616308a
2 files changed, +138 −6
  • internal/activator/activator.go +64 −6
  • internal/activator/waketiming_test.go +74 −0

CI cancelled — superseded by a newer push — run details

diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 426b8c5e9..41f326995 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -620,7 +620,11 @@ func (a *Service) releaseFunc(e *actEntry, stamp bool) func() {
func (a *Service) wake(e *actEntry) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
+ wakeStart := a.now()
w, err := a.doWake(ctx, e.route)
+ if w != nil {
+ w.wakeTook = a.now().Sub(wakeStart)
+ }
// The member-facing record of this wake (spec §5): composed here, where
// the route and the whole error are in hand — the journal line below the
@@ -628,11 +632,7 @@ func (a *Service) wake(e *actEntry) {
if err != nil {
emitEvent(a.Logs, e.route, "error", memberSafeWakeReason(err))
} else {
- msg := "instance woke"
- if w.spawnVersion != "" {
- msg += " (version " + w.spawnVersion + ")"
- }
- emitEvent(a.Logs, e.route, "info", msg)
+ emitEvent(a.Logs, e.route, "info", wakeEventMessage(w))
}
a.mu.Lock()
@@ -668,6 +668,44 @@ func (a *Service) wake(e *actEntry) {
close(e.ready)
}
+// wakeEventMessage is the member-facing wake line, split out so the one thing
+// an app can actually scrape has a test of its own.
+//
+// It carries the wake's timing because an instance cannot time its own wake:
+// the activator execs the binary, the process starts, opens SQLite, runs its
+// migrations and parses its templates, and only THEN can the app's own code
+// observe anything. So a cold-start budget's most important number is the one
+// the app structurally cannot produce, and every app on the platform meets
+// the same wall (discussion #6).
+//
+// Two figures, because one would not settle the question the budget is
+// really asking. `spawn` is Backend.Start alone — exec to the socket
+// accepting, which is the app's OWN boot and none of the platform's work.
+// The total around it is the lease, the restore, the seed and replication.
+// An app reading "woke in 1.4s (spawn 1.2s)" knows its boot is the problem;
+// reading "woke in 1.4s (spawn 200ms)" knows it is not, which is the
+// distinction #6 says cannot be made at all today.
+//
+// Rounded to a millisecond: this is a log line a person reads and a script
+// greps, and microseconds of a multi-hundred-millisecond wake are noise that
+// makes both harder.
+func wakeEventMessage(w *wokenInstance) string {
+ msg := "instance woke"
+ if w == nil {
+ return msg
+ }
+ if w.wakeTook > 0 {
+ msg += " in " + w.wakeTook.Round(time.Millisecond).String()
+ if w.spawnTook > 0 {
+ msg += " (spawn " + w.spawnTook.Round(time.Millisecond).String() + ")"
+ }
+ }
+ if w.spawnVersion != "" {
+ msg += " (version " + w.spawnVersion + ")"
+ }
+ return msg
+}
+
// wokenInstance is a successful wake's yield. sidecar is nil when the host has
// none staged — or when the spawn failed, which is deliberately not an error:
// members > bots, so a host whose sidecar won't start still serves (the
@@ -678,6 +716,18 @@ type wokenInstance struct {
sidecar Proc
sidecarWanted bool
spawnVersion string // live/<host> target read at exec — for build convergence
+ // spawnTook is how long Backend.Start took: exec to the socket accepting,
+ // which is the instance's OWN boot and nothing of the platform's. wakeTook
+ // is the whole wake around it, filled in by wake() rather than here
+ // because the caller owns the clock that bounds it.
+ //
+ // They exist because an instance cannot time its own wake — almost all of
+ // it happens before the app's code can observe anything — so the figure
+ // an app most wants for a cold-start budget is the one it structurally
+ // cannot produce (discussion #6). Reported on the wake event, where
+ // `carlos logs` already carries the line.
+ spawnTook time.Duration
+ wakeTook time.Duration
}
func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance, error) {
@@ -727,7 +777,15 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance
// on boot, so waiting for the socket guarantees the file exists for
// litestream to open. Writes that land in the gap are picked up by the
// replica's first sync.
+ // Timed, because this is the half an app CANNOT see. Backend.Start execs
+ // the binary and waits until its socket accepts, so it contains the whole
+ // of the instance's own boot — opening SQLite, running migrations,
+ // parsing templates — and none of the platform's lease, restore or
+ // replication work. An app measuring its own TTFB starts the clock after
+ // all of it (discussion #6).
+ spawnStart := a.now()
proc, spawnVer, err := a.backend.Start(ctx, rt)
+ spawnTook := a.now().Sub(spawnStart)
if err != nil {
_ = lease.Release(ctx)
return nil, fmt.Errorf("start %s: %w", host, err)
@@ -752,7 +810,7 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance
// marked requests pass through the edge while we're awake). A spawn
// failure is loud but never fails the wake: members come before bots, and
// the sweeper retries with backoff.
- w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer}
+ w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer, spawnTook: spawnTook}
if a.backend.HasSidecar(rt) {
w.sidecarWanted = true
if sc, err := a.backend.StartSidecar(ctx, rt); err != nil {
diff --git a/internal/activator/waketiming_test.go b/internal/activator/waketiming_test.go
new file mode 100644
index 000000000..3b1e5040d
--- /dev/null
+++ b/internal/activator/waketiming_test.go
@@ -0,0 +1,74 @@
+package activator
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// The wake's timing, on the one line an app can actually scrape
+// (discussion #6). An instance cannot time its own wake — almost all of it
+// happens before the app's code can observe anything — so a cold-start
+// budget's most important figure is the one the app structurally cannot
+// produce.
+
+func TestWakeEventMessageCarriesBothFigures(t *testing.T) {
+ msg := wakeEventMessage(&wokenInstance{
+ wakeTook: 1400 * time.Millisecond,
+ spawnTook: 1200 * time.Millisecond,
+ spawnVersion: "7507a33",
+ })
+ for _, want := range []string{"instance woke", "1.4s", "spawn 1.2s", "7507a33"} {
+ if !strings.Contains(msg, want) {
+ t.Errorf("wake message %q is missing %q", msg, want)
+ }
+ }
+}
+
+// The two figures have to be separable, because the whole point is telling
+// "our boot is slow" from "the wake path is slow". A message carrying only a
+// total answers neither.
+func TestWakeEventMessageSeparatesSpawnFromTheRest(t *testing.T) {
+ slowBoot := wakeEventMessage(&wokenInstance{wakeTook: 1400 * time.Millisecond, spawnTook: 1200 * time.Millisecond})
+ slowPlatform := wakeEventMessage(&wokenInstance{wakeTook: 1400 * time.Millisecond, spawnTook: 200 * time.Millisecond})
+ if slowBoot == slowPlatform {
+ t.Fatalf("a slow boot and a slow wake path read identically: %q", slowBoot)
+ }
+ if !strings.Contains(slowPlatform, "spawn 200ms") {
+ t.Errorf("message %q does not carry the spawn figure", slowPlatform)
+ }
+}
+
+// Unmeasured is not zero. A wake with no timing (a backend or a test that
+// does not fill them) must read exactly as the line did before, rather than
+// claiming an instant wake.
+func TestWakeEventMessageSaysNothingItDoesNotKnow(t *testing.T) {
+ if got := wakeEventMessage(&wokenInstance{}); got != "instance woke" {
+ t.Errorf("untimed wake = %q, want the bare line", got)
+ }
+ if got := wakeEventMessage(&wokenInstance{spawnVersion: "abc1234"}); got != "instance woke (version abc1234)" {
+ t.Errorf("untimed wake with a version = %q", got)
+ }
+ if got := wakeEventMessage(nil); got != "instance woke" {
+ t.Errorf("nil wake = %q, want the bare line", got)
+ }
+ // A total with no spawn figure still reports the total: half an answer
+ // beats none, and the alternative is dropping a measurement that was
+ // actually taken.
+ if got := wakeEventMessage(&wokenInstance{wakeTook: 900 * time.Millisecond}); !strings.Contains(got, "900ms") {
+ t.Errorf("wake with only a total = %q", got)
+ }
+}
+
+// Rounded to a millisecond: a log line a person reads and a script greps,
+// where microseconds of a several-hundred-millisecond wake are noise in both
+// directions.
+func TestWakeEventMessageRoundsToMilliseconds(t *testing.T) {
+ msg := wakeEventMessage(&wokenInstance{
+ wakeTook: 812*time.Millisecond + 417*time.Microsecond,
+ spawnTook: 640*time.Millisecond + 903*time.Microsecond,
+ })
+ if !strings.Contains(msg, "812ms") || !strings.Contains(msg, "spawn 641ms") {
+ t.Errorf("wake message %q is not rounded to milliseconds", msg)
+ }
+}