Skip to content

feat(cardinal): add WatchSystemsTiming and ProfileSystems RPCs - #922

Open
ryanditjia wants to merge 24 commits into
mainfrom
ryandi/cardinal-metrics-mvp
Open

feat(cardinal): add WatchSystemsTiming and ProfileSystems RPCs#922
ryanditjia wants to merge 24 commits into
mainfrom
ryandi/cardinal-metrics-mvp

Conversation

@ryanditjia

@ryanditjia ryanditjia commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add WatchSystemsTiming for lightweight total system timing.
  • Add ProfileSystems for per-system spans captured only while a profiler is connected.
  • Keep profiling decisions fixed for each tick, so profiles are never partial.
  • Let each RPC stream own its batching; the collector publishes completed ticks through bounded, latest-wins queues.
  • Prevent pre-reset and post-reset ticks from appearing in the same batch.
  • Keep StreamPerf as a deprecated compatibility adapter.
  • Regenerate the Go, TypeScript, and C# clients.

Stack

Built on #937.

Verification

  • go test ./pkg/... -count=1
  • go test -race ./pkg/cardinal/internal/performance -count=1
  • task lint
  • buf lint
  • buf breaking --against '.git#branch=origin/main'

@ryanditjia
ryanditjia force-pushed the ryandi/cardinal-metrics-mvp branch 2 times, most recently from a701629 to bc20f63 Compare July 29, 2026 17:27
@ryanditjia ryanditjia changed the title Add lightweight system timing and profiling RPCs Add WatchSystemsTiming and ProfileSystems RPCs Jul 29, 2026
@ryanditjia ryanditjia changed the title Add WatchSystemsTiming and ProfileSystems RPCs feat(cardinal): add WatchSystemsTiming and ProfileSystems RPCs Jul 29, 2026
@ryanditjia
ryanditjia force-pushed the ryandi/cardinal-metrics-mvp branch 3 times, most recently from 07fe4a2 to 2ce4e3c Compare July 29, 2026 18:10
@ryanditjia
ryanditjia marked this pull request as ready for review July 29, 2026 18:27
Comment thread pkg/cardinal/cardinal.go Outdated
w.world.Tick()

w.debug.recordTick(w.currentTick.height, timestamp)
if w.debug != nil {

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.

we dont need to check there because the nil check happens on the debug function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in b8fe654. This avoids calling time.Since(systemPhaseStartedAt) before the nil-safe recordTick when debug == nil and systemPhaseStartedAt is zero-valued.

Comment thread pkg/cardinal/cardinal.go Outdated
// Tick ECS world.
var systemPhaseStart time.Time
if w.debug != nil {
systemPhaseStart = time.Now()

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.

Can we just use timestamp?
I get that it would include the drain time but its very small amount of time and then we are not polluting so much of the tick with debug code?

Not 100% of this just leaving it for you to think about

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I thought of this too, went back and forth until I settled to keep systemPhaseStart, and later renaming it to systemPhaseStartedAt.

Reason: it might seem harmless now (only drain time as the pollutant), but it would be dishonest later if, however unlikely, we add something more other than drain. I’d rather we keep it extremely honest: purely systems.

@ryanditjia
ryanditjia requested a review from rmrt1n July 29, 2026 19:05
@ryanditjia
ryanditjia force-pushed the ryandi/cardinal-metrics-mvp branch from deb8657 to f948500 Compare August 3, 2026 11:01
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

Claude finished @ryanditjia's task in 10m 34s —— View job


Code Review — feat(cardinal): add WatchSystemsTiming and ProfileSystems RPCs

  • Gather context (diff, prior review threads, commit range)
  • Review hand-written Go changes (cardinal.go, debug.go, system.go, collector.go)
  • Review proto contract + generated-client sync
  • Verify findings (go build, go vet, go test -race ./pkg/cardinal/... — all pass)
  • Post review

The core of this — the TickCapture{Generation, SystemSpans} latch — is genuinely well-built. I traced the subscribe/unsubscribe/reset interleavings looking for a hole and didn't find one:

  • A subscriber added mid-tick has active == false and is skipped in RecordTick, so it never sees a partially-captured tick.
  • capture.SystemSpans is the OR over subscribers at StartTick, and active is set in the same critical section — so every tick delivered to a profile subscriber necessarily had span capture on. That's what makes dropping the old if !ts.Profiled { continue } filter in profileBatchToProto correct, which resolves my earlier comment about the pre-subscribe hole. 👍
  • Generation correctly drops a tick that straddles a Reset(), and w.currentTick = Tick{} before w.world.Init() stops init systems from inheriting a stale latch.
  • Stale currentSpans from a finished profiling session can't leak into a new one: StartTick truncates whenever any subscriber exists, and capture.SystemSpans is false on the tick a new profile client joins.

Findings below are ordered by severity. The first is a new wire-visible behavior change from 9e0b1d15.


1. A single response's ticks array can now contain non-contiguous tick heights

pkg/cardinal/debug.go:172-200 + pkg/cardinal/internal/performance/collector.go:185-201

Before 9e0b1d15, the collector built each batch atomically under c.mu and dropped whole batches on a non-blocking send — so the ticks inside one message were always consecutive. Now the collector pushes one TickTimeline per tick through offerLatest (drop-oldest), and streamTickBatches appends whatever it receives until it has batchSize. If the producer overruns the buffer while the stream is mid-batch, the drop lands inside the batch being assembled.

Confirmed empirically against this branch (NewCollector(8), batch of 4, ticks produced between reads):

single emitted batch heights: [0 1 6 7]

SystemsTiming.tick_height makes this detectable, but nothing tells the client to look. An Editor chart that plots response.ticks in order — the natural reading of an array of consecutive ticks in one message — will silently compress the gap and mis-scale the x-axis.

Two things worth doing:

  • Flush on discontinuity in streamTickBatches, so each emitted batch is internally contiguous and the gap always lands on a message boundary:
if len(pending) > 0 && (tick.Generation != generation || tick.TickHeight != pending[len(pending)-1].TickHeight+1) {
    if err := send(pending); err != nil {   // emit the short batch rather than discarding it
        return err
    }
    pending = pending[:0]
}

(Generation changes should still discard rather than send.)

  • State the contract in debug.proto, which is what the Editor/Unity teams actually read and what feeds the generated TS/C# doc comments — neither WatchSystemsTiming nor ProfileSystems currently mentions that sends are lossy. The Go-side note at debug.go:130-131 ("slow clients may miss samples") doesn't reach non-Go clients, and ProfileSystems says nothing at all.

Fix this →


2. streamTickBatches never flushes a partial batch, so Pause/Step freeze the metrics stream

pkg/cardinal/debug.go:181-199

send fires only at len(pending) == batchSize, and batchSize ≈ TickRate. Pause and Step are RPCs on this same DebugService and are exactly what the Editor drives while someone is looking at the metrics screen:

  • Pause: ticks stop, up to batchSize-1 ticks sit in pending indefinitely. On resume they're emitted stitched onto post-resume ticks, so one message straddles the pause with no marker.
  • Step: you need batchSize steps (~60 at a 60Hz tick rate) before a single sample reaches the client. Stepping through a tick to inspect its timing — a fairly obvious use of this screen — shows nothing.

A time.Ticker case in the select that flushes a non-empty pending would cover both, and also bounds worst-case latency for low tick rates.

Fix this →


3. ts.Add(time.Since(ts)) in system.go:57-60 is vestigial and loses the monotonic clock

Still unaddressed from the previous pass, and it's now purely cost: start_offset_ns is measured against SystemPhaseStartedAt (debug.go:238), so anchoring span endpoints to world.currentTick.timestamp buys nothing.

ts.Add(time.Since(ts)) evaluates to "now" either way, but when ts carries no monotonic reading the result doesn't either — so span.StartTime.Sub(origin) in systemSpansToProto has monotonic on one side only and silently falls back to wall-clock subtraction, which is what the startOffset < 0 clamp is papering over. There's a live caller doing exactly this: pkg/plugin/physics2d/test/utils_test.go:57 passes time.Unix(int64(i), 0) with Debug: &debug set at line 31.

time.Now() gives monotonic unconditionally and drops two world.currentTick reads per system on the hot profiled path:

startTime := time.Now()
system(state)
endTime := time.Now()

Fix this →


4. Legacy proto messages carry deprecation prose but no deprecated option

proto/worldengine/cardinal/v1/debug.proto:153,157,162

StreamPerfRequest, PerfBatch, and TickTimeline say "Kept for compatibility; use …" in comments, but only the StreamPerf rpc has option deprecated = true. So the generated TS/C#/Go types get no @deprecated / [Obsolete] marker and client IDEs won't warn on the message types — only on the method. Adding option deprecated = true; to the three messages makes the signal consistent.


5. PR description no longer matches the code

The description says StreamPerf was replaced and "this intentionally does not preserve compatibility with StreamPerf", but the final state keeps it fully working via legacyPerfBatchToProto (debug.go:159-170, :223-233) with option deprecated = true. Worth updating before merge so the changelog and the Editor team aren't misled.


Nits

  • streamTickBatches's send contract is undocumented (debug.go:172-177): pending is truncated and reused immediately after send returns. That's safe today because connect's stream.Send marshals synchronously, but a future buffered/async send would corrupt data with no visible failure. One line — "send must not retain batch" — pins it. The existing test already copies defensively (debug_internal_test.go:319).
  • collector.go:76: StartTick returns early on subscriberCount == 0 without truncating currentSpans, so the collector holds the last profiled tick's span slice until the next session. Correctness is fine (it's always truncated before the next capture); it's just free to drop.
  • collector.go:81-85: RecordSpan takes c.mu per system, but currentSpans is only ever touched by StartTick/RecordSpan/RecordTick/Reset, all on the tick goroutine. The lock is uncontended so it's cheap, but a comment stating the single-writer invariant would explain why it's kept.

Verified locally: go build ./..., go vet ./pkg/cardinal/..., and go test -race -count=1 ./pkg/cardinal/... all pass (incl. internal/performance). buf/protoc aren't available in this environment, so I checked the generated TS/Go/C# by inspection rather than regenerating — the new messages, the @deprecated marker on StreamPerf, and the doc comments all line up with debug.proto.

No code changes pushed — this is review feedback only.
· ryandi/cardinal-metrics-mvp

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

❌ 10 Tests Failed:

Tests completed Failed Passed Skipped
199 10 189 1
View the full list of 10 ❄️ flaky test(s)
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../basic/shards/game [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../pkg/template/multi-shard [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../multi-shard/shards/game [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../plugin/physics2d/test [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../shards/game/system [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../shards/game/system [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../shards/chat/system [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../shards/chat/command [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../multi-shard/shards/chat [build failed]
::TestMain

Flake rate in main: 100.00% (Passed 0 times, Failed 3 times)

Stack Traces | 0s run time
FAIL	github..../shards/game/command [build failed]

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@ryanditjia
ryanditjia force-pushed the ryandi/cardinal-metrics-mvp branch from f948500 to a6dc87f Compare August 3, 2026 15:57
@ryanditjia
ryanditjia changed the base branch from main to ryandi/fix-generated-wire-code August 3, 2026 15:58
@ryanditjia
ryanditjia force-pushed the ryandi/cardinal-metrics-mvp branch 3 times, most recently from a0cec02 to cbcb34b Compare August 5, 2026 12:34
@ryanditjia
ryanditjia changed the base branch from ryandi/fix-generated-wire-code to main August 5, 2026 12:34
Comment thread pkg/cardinal/debug.go Outdated
select {
case batch := <-ch:
response := profileBatchToProto(batch)
if len(response.GetTicks()) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

profileBatchToProto filters out every !Profiled tick, so a batch that mixes pre-subscribe and post-subscribe ticks silently loses the timing data for the pre-subscribe ones — and a batch made entirely of them is dropped here.

SystemsProfile.timing is populated independently of spans, so those ticks could be emitted with an empty spans list instead, giving the Editor a continuous timeline from the moment the stream opens rather than a hole of up to batchSize ticks (~1s at the default perfBatchIntervalSec).

If the hole is deliberate (client can't distinguish "profiled, no systems ran" from "not profiled"), a comment saying so would help — TestProfileBatchToProtoFiltersUnprofiledTicks already asserts that a profiled-but-empty tick stays visible, which makes the two cases ambiguous on the wire.

Comment on lines 197 to 209
for i, sub := range c.subscribers {
if sub == ch {
if sub.ch == ch {
c.subscribers = append(c.subscribers[:i], c.subscribers[i+1:]...)
c.subscriberCount.Add(-1)
if sub.requestsSystemSpans {
c.systemSpanSubscriberCount.Add(-1)
}
if len(c.subscribers) == 0 {
c.pending = c.pending[:0]
}
return
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two things here:

  1. The new if len(c.subscribers) == 0 { c.pending = c.pending[:0] } branch is the one piece of new state logic in this file with no test. Every collector test either uses batchSize == 1 (so pending is empty by the time Unsubscribe runs) or never resubscribes after unsubscribing. A test like: NewCollector(5) → subscribe → record 3 ticks → unsubscribe → resubscribe → record 5 ticks → assert the batch starts at the post-resubscribe height, would pin the "a new subscriber never sees ticks from a previous session" invariant.

  2. currentSpans isn't cleared alongside pending. After the last ProfileSystems client disconnects, the collector holds up to len(systems) TickSpan values (including their SystemName strings) until the next profiling session calls StartTick. Small, but it's free to drop here since you already hold the lock.

Comment on lines 84 to 89
@@ -68,23 +88,44 @@ func (c *Collector) RecordSpan(span TickSpan) {
c.currentSpans = append(c.currentSpans, span)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional, but worth considering given this PR adds a benchmark for exactly this path: currentSpans is touched only by StartTick, RecordSpan, RecordTick, and Reset — all four run on the tick goroutine (Reset via w.reset(), which is driven from resetChan in the run loop). Nothing outside that goroutine ever reads it; the flush copies it under the lock but from the same goroutine.

So the c.mu acquisition per system is protecting single-goroutine state. With 50 systems that's 50 lock/unlock pairs per tick on the profiled path. BenchmarkCollectorCapture/profile_50_systems on this branch reports ~5.0µs/op vs ~119ns for watch_timing, so it's a meaningful slice of the profiling overhead.

If you'd rather keep the mutex for safety, a comment on currentSpans stating the single-writer invariant would at least document why it's cheap to keep (uncontended).

Comment on lines +70 to +72
// StartTick returns whether the next tick should capture per-system spans. The
// result must be carried through RecordTick so one tick cannot be partially
// profiled when a profile subscriber connects or disconnects mid-tick.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: "the next tick" — the caller applies this to the tick it's about to run, not the following one (cardinal.go:220 assigns it into w.currentTick and w.world.Tick() runs immediately after). "the tick that is about to run" reads truer.

Comment thread pkg/cardinal/system.go
Comment on lines 53 to 54
ts := world.currentTick.timestamp
startTime := ts.Add(time.Since(ts))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Now that start_offset_ns is measured against SystemPhaseStartedAt (debug.go:238) instead of TickStart, anchoring span endpoints to world.currentTick.timestamp is vestigial — and it actively costs precision.

ts.Add(time.Since(ts)) evaluates to "now" either way, but it launders away the monotonic clock reading whenever the caller passes a wall-only timestamp:

  • time.Since(ts) on a ts with no monotonic reading returns a wall-clock delta.
  • ts.Add(d) on such a ts produces a time.Time with no monotonic reading.
  • span.StartTime.Sub(ts.SystemPhaseStartedAt) in debug.go:238 then has monotonic on only one side, so Sub silently falls back to wall-clock subtraction — vulnerable to NTP steps, which is exactly what the startOffset < 0 clamp is papering over.

This isn't hypothetical: World.Tick is exported and called with wall-only timestamps today, with debug enabled — pkg/plugin/physics2d/test/utils_test.go:57 (time.Unix(int64(i), 0), with Debug: &debug at line 31) and bench_test.go:48.

time.Now() gives monotonic on both sides unconditionally, and drops two world.currentTick reads per system on the hot profiled path:

Suggested change
ts := world.currentTick.timestamp
startTime := ts.Add(time.Since(ts))
startTime := time.Now()

(with the matching endTime := time.Now() and the now-unused ts removed).

Comment on lines +34 to +40
// WatchSystemsTiming streams the time spent executing Cardinal systems each
// tick without enabling per-system span capture.
rpc WatchSystemsTiming(WatchSystemsTimingRequest) returns (stream WatchSystemsTimingResponse);

// ProfileSystems captures per-system spans while the stream is open.
// Cancelling the stream stops detailed span capture.
rpc ProfileSystems(ProfileSystemsRequest) returns (stream ProfileSystemsResponse);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both streams can silently drop data, and the .proto — the contract the Editor team actually reads, and the source of the generated TS/C# doc comments — doesn't say so:

  • Sends are non-blocking into a subscriberChanBuf = 4 channel (collector.go:11, :146-151), so a client stalled for more than ~4 batches (~4s at the default perfBatchIntervalSec = 1) loses batches with no error.
  • ProfileSystems additionally suppresses any batch whose ticks are all unprofiled (debug.go:205), so the first ~1s after the stream opens can be a hole.

The Go-side comment on WatchSystemsTiming (debug.go:169) does say "slow clients may miss samples", but ProfileSystems says nothing, and neither statement reaches non-Go clients. Since SystemsTiming.tick_height is monotonic, gaps are detectable — worth stating here that clients must key off tick_height and tolerate discontinuities rather than assuming a contiguous series.

@ryanditjia
ryanditjia force-pushed the ryandi/cardinal-metrics-mvp branch from af47efa to 9e0b1d1 Compare August 9, 2026 17:38
@ryanditjia
ryanditjia changed the base branch from main to ryandi/protobuf-descriptor-introspection-v2 August 9, 2026 17:38
Base automatically changed from ryandi/protobuf-descriptor-introspection-v2 to main August 13, 2026 17:27
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.

2 participants