atelet: emit per-actor usage events from the stats sweep - #1206
atelet: emit per-actor usage events from the stats sweep#1206Tim Bai (baizhenyu) wants to merge 4 commits into
Conversation
Jeff Luo (JeffLuoo)
left a comment
There was a problem hiding this comment.
Could you update docs/observability.md in the logging section to include instructions for using the new per-actor usage events?
We can update doc in a separate PR after implementation is finalized and submitted. Otherwise, we will need to maintain sync of implementation and doc which is not very efficient. |
Da Huang (git286)
left a comment
There was a problem hiding this comment.
Actor Suspend -> Resume latency (sub-second) is one of the most important performance SLO the project is trying to achieve. Right now the sampleFirst sit in the critical path synchronously and could impact the latency in the unhappy case (I see the timeout is 10 seconds for worst case).
I think we should make the sampling async to reduce the impact on the critical path latency.
b49fe59 to
91e3fff
Compare
Done in 91e3fff:
|
|
I would double check with Benjamin Elder (@BenTheElder) to make sure that the synchronous sampling logic in the running -> suspend path is acceptable. (Happy case few ms, but worst case 10s latency?) (Given the first sampling logic has become async so suspend -> running latency won't be affected) |
Yikes, 10s in the hot path is way outside of our targets, what other options did we consider? |
The final sample has to be a blocking call, otherwise, it potentially gets lost forever (cgroup got destroyed). However, we can decrease the |
So in theory we want say, 100ms, but realistically we are something at least XXXms for gVisor operations, and slower for uVM at the moment. Slowing that down in any way is going the wrong direction as we're already pretty far from the desired latency. Can we just sample while it's online and not sample during snapshot etc. |
The periodic resource event emission during normal operation has already been implemented in this PR. However, the final resource utilization event is very important before actor suspend. This event contains the metric of total CPU usage of current actor session. The periodic resource event is unable to provide this information accurately. Alternatively, we can make this operation async (best effort) so that it does not block the hot path. WDYT? |
|
I don't think async works here. The whole point of the final sample is that it reads the CPU counter before the checkpoint destroys it. If we fire-and-forget, the read races the teardown and loses most of the time, so we'd ship the feature with its main guarantee broken. Correct me if I am wrong, but I think we don't have to choose between latency and the sample. The Checkpoint handler already does a bunch of prep before dispatching to the ateom (read sandbox record, ensure assets, dial, build spec). We can start the sample in a goroutine at the top of the handler and join it right before dispatch. The read takes single-digit ms and the prep takes way longer, so in the happy case it's already done by the time we need it with zero added latency, not just "small". If it's somehow still not done at dispatch time, we wait a short grace (say 50ms), then log the lost-sample warning and proceed. So the worst case drops from 10s to 50ms, and only when the stats path is already broken. Longer term the clean fix is to have ateom take the sample itself as the first step of CheckpointWorkload and return it in the response, that's literally "sample while it's online" with zero added latency and no race at all. But it's a proto change touching both runtimes, so I'd do that as a follow-up. WDYT about overlap + short grace in this PR, piggyback as follow-up? |
The overlap with a short grace period makes sense to me, but I have reservations about the long-term approach. I considered moving the final sampling to ateom before; however, if our goal is zero overhead on checkpoint operations, blocking the process to collect metrics will inevitably introduce latency—regardless of whether it's handled in atelet via a dedicated RPC or embedded directly into the checkpoint RPC via ateom. |
One thing that hasn't come up in this thread: the latency question aside, there's a correctness bug in the current ordering that the ateom approach would fix for free. Right now we emit the final sample before calling CheckpointWorkload. If the checkpoint fails transiently, the workload keeps running and the control plane retries so we emit a second final for the same session. Anyone summing finals now double counts almost the whole session, and the events carry no epoch/attempt id to dedup on. If ateom takes the sample as part of CheckpointWorkload and returns it in the response, this problem can't happen: we only emit when the checkpoint actually succeeded, so it's always exactly one final per session. Same trick would work for Terminate, which currently emits no final at all. So I'd frame the follow-up as a correctness fix, not a latency optimization. On the latency concern: the read is cheap on gVisor, and ateom can overlap it with its own pre-destructive prep, same as what we're doing here. So it doesn't have to add anything to the critical path. |
Updated in the latest commits — the final sample now follows the overlap shape proposed above, tightened one step further:
Net latency: zero added in the happy case; worst case 50ms, paid only when the stats path is already broken. The read timeout (500ms) bounds only a background goroutine, never the handler. On the ateom-side follow-up: with emit-on-success in atelet, the duplicate-final correctness issue is fixed here, so what the proto change would still buy is exact-at-freeze accuracy (the current design undercounts by the CPU burned between read and freeze — prep-length on a session-length measurement) and dropping the 50ms grace. The Terminate gap doesn't need it either: the same begin/join/emit pattern fits the Terminate handler as a small follow-up. |
Update doc in a follow up PR
The events channel is the per-actor half of the usage telemetry split: the poller's metrics aggregate to the bounded template-level label set, and everything carrying actor or atespace identity travels here instead, as structured log events -- never a TSDB series. Each executing actor's sample from the poller's existing sweep becomes one JSON record on atelet's stdout, using the same label vocabulary as actorlog's lifecycle events (ateattr.ActorLogLabels, including the GCE logging.googleapis.com/labels spelling), so one log filter on the actor uid returns an actor's container logs, lifecycle transitions, and usage samples interleaved. Identity comes solely from each sample's echo, per the stats RPCs' attribution contract; the measurements ride as payload fields, stamped with an event kind so future kinds can join without reshaping the record. Idle workers emit nothing: an idle fleet is silent by design. Events also carry the ate.workerpool.namespace/name pair the metric labels already carry, resolved by the sweep's own pod list, so a pool-level metric spike can pivot to the actors behind it -- pool membership lives on the worker pod and is unrecoverable from logs once the pod is gone, so it must be stamped at emission. An unresolved pod emits without the pair, following the metric channel's rule. The sweep feeds both channels from the same probe, so the events add no RPC load, and the one knob governs both: --actor-stats-poll-interval 0 disables the subsystem.
a7240ce to
10986de
Compare
|
Updated the PR, removed the lifecycle event and only keep periodic ones. |
…azily Two emitter-construction fixes from review. Usage events are a data feed, not leveled diagnostics: quieting a node with --log-level=warn must not silently sever them, so the emitter now writes through its own fixed-level handler instead of the serverboot logger, and the subsystem's one off-switch stays --actor-stats-poll-interval=0. The records still carry level INFO on the wire, so nothing downstream changes. metadata.OnGCE probes the metadata server -- seconds of timeout off GCE -- and was called synchronously on atelet's boot path just to pick the label-group key. The key now resolves once, at first emit, on the poller's sweep goroutine, which nobody waits on.
Two follow-ups on the usage-event emitter from review. The label group's spelling was chosen in two places -- actorlog picked it for container logs and lifecycle events, the usage emitter picked it again for itself. Export actorlog.LabelsKey as the one place the choice lives, so every emitter of the actor-identity label group promotes into Cloud Logging the same way, and going vendor-neutral later means changing one function. The lazy resolution moved the metadata probe off atelet's boot path but onto the first emit. Warm it from startStatsPoller on a throwaway goroutine instead: sync.OnceValue lets an emit that arrives first simply wait for the in-flight probe, so neither the boot path nor the sweep pays for it.
# Conflicts: # cmd/atelet/statspoller_test.go
|
One robustness concern: emit writes to stdout synchronously inside the sweep's errgroup, and a write to a full pipe blocks forever, since there's no timeout on stdout writes. So if the log consumer stalls (disk full, log rotation stuck), one stuck write wedges g.Wait(), the tick loop stops, and the metrics freeze too. They'll keep re-serving the last snapshot with no error. Before this PR the sweep never wrote to stdout, so log-pipe health and metric health were independent; this couples them, and it bites exactly during disk incidents when you'd be looking at these dashboards. Suggestion: make the emitter non-blocking. A small bounded channel drained by one writer goroutine, dropping and counting when full, would do it. Dropping is safe here since these are cumulative samples: the next healthy tick repairs the gap. Losing events when stdout is dead is unavoidable anyway; losing the metrics with them isn't. |
| labels := ateattr.ActorLogLabels(a, "") | ||
| if pool != (workerPoolRef{}) { | ||
| labels[string(ateattr.WorkerPoolNamespaceKey)] = pool.namespace | ||
| labels[string(ateattr.WorkerPoolNameKey)] = pool.name |
There was a problem hiding this comment.
pool != (workerPoolRef{}) treats a half populated ref (namespace set, name empty) as resolved and emits an empty-string ate.workerpool.name label, contradicting the doc comment's 'omits the label pair rather than emitting empty strings'.
Part of #896 (Phase 1 of #550): the events channel — per-actor usage samples as structured log events. With this, both halves of #174's cardinality split exist: template-level metrics in the TSDB (#961), and everything carrying actor/atespace identity here, in the log store.
The events
One JSON record per executing actor per sweep on atelet's stdout, riding the poller's existing probe — no extra RPC load, and one knob governs both channels (
--actor-stats-poll-interval 0disables the subsystem). Idle workers emit nothing: an idle fleet is silent by design.Records use the same label vocabulary as actorlog's lifecycle events (
ateattr.ActorLogLabels, including the GCElogging.googleapis.com/labelsspelling) — so one Cloud Logging filter onlabels."ate.actor.uid"returns an actor's container logs, lifecycle transitions, and usage samples interleaved. Identity comes solely from each sample's echo, per the stats RPCs' attribution contract. Measurements ride as payload fields (kind, class, source, the four numbers,observed_at_unix_nano); thekindfield (today alwaysperiodic) lets future kinds join without reshaping the record.Events also carry the
ate.workerpool.namespace/namepair the metric labels already carry, resolved by the sweep's own pod list, so a pool-level metric spike can pivot to the actors behind it — pool membership lives on the worker pod and is unrecoverable from logs once the pod is gone, so it must be stamped at emission. An unresolved pod emits without the pair, following the metric channel's rule.Scope
Earlier revisions also took first/final lifecycle samples from the Run/Restore/Checkpoint handlers. Per the review discussion on suspend/resume latency, those are descoped from this PR: it now touches no lifecycle path at all. The bracket design (including taking the final sample inside ateom's CheckpointWorkload and echoing it in the response) moves to a follow-up under #896.
Isolation
The poller dials its own short-lived connection per probe (
dialAteomStats) and never touches the lifecycle RPCs' cached clients — after review on #961 the isolation is structural.Validated live on ate-dev
Periodic events read back from Cloud Logging via
labels."ate.actor.name", one per executing actor per minute, withate.actor.uidconfirmed promoted intoLogEntry.labels(filterable) — the claim only production could prove.Consumer note: aggregate freely in the log store, but a log-based metric built over these events must label only by the bounded set (template, class, source, pool) — promoting actor identity into a metric label would reintroduce exactly the cardinality #174 keeps out of the TSDB.
Part of #896. Part of #550.