Skip to content

feat(snapshot): four-phase timing (A/B/C/D) per table - #255

Merged
cnlangzi merged 5 commits into
mainfrom
fix/snapshot-performance-monitoring-four-phase-timing
May 13, 2026
Merged

feat(snapshot): four-phase timing (A/B/C/D) per table#255
cnlangzi merged 5 commits into
mainfrom
fix/snapshot-performance-monitoring-four-phase-timing

Conversation

@cnlangzi

Copy link
Copy Markdown
Owner

Summary

Implements per-table, per-phase timing (A/B/C/D) for snapshot processing to identify bottlenecks.

Changes

  • CaptureResult gains and fields to signal table completion to Runtime
  • SnapshotCapturer.Fetch tracks Phase A (ReadMs) and Phase B (BuildMs) per batch via timing instrumentation around DB query and row scanning
  • Runtime.Run tracks Phase C (TransformMs) and Phase D (WriteMs) per batch, calling when
  • TableProgress now includes , , , fields
  • Added SnapshotFinalizer interface for Runtime → SnapshotCapturer timing feedback

Four Phases

Phase Description Location
A Read - executing paged query SnapshotCapturer.Fetch
B Build - scan rows and build batch SnapshotCapturer.Fetch
C Transform - plugin transformation Runtime.Run
D Write - sink write Runtime.Run

Verification

  • ✅ CaptureResult.TableDone and Table fields correctly signal last batch of each table
  • ✅ SnapshotCapturer.Fetch returns accurate ReadMs and BuildMs per batch
  • ✅ Runtime.Transform and SinkWrite are independently timed
  • ✅ FinalizeTable correctly writes transformMs/writeMs to TableProgress
  • ✅ Progress() returns TableProgress with all four timing fields populated per table

Fixes #254

- CaptureResult gains TableDone and Table fields to signal table completion
- SnapshotCapturer.Fetch tracks Phase A (ReadMs) and Phase B (BuildMs) per batch
- Runtime.Run tracks Phase C (TransformMs) and Phase D (WriteMs) per batch
- FinalizeTable called from Runtime when TableDone==true to record C+D timing
- TableProgress now includes ReadMs, BuildMs, TransformMs, WriteMs for bottleneck identification
- SnapshotFinalizer interface allows Runtime to signal timing back to SnapshotCapturer

Fixes #254
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR instruments per-table timing for four snapshot phases: Read, Build, Transform, and Write. core.CaptureResult gains a Table field; Capturer gains RecordTiming(table, transformMs, writeMs). Runtime.Run measures per-batch Transform and SinkWrite durations and calls RecordTiming(result.Table, transformMs, writeMs). SnapshotCapturer records Read/Build timings in Fetch, exposes RecordTiming to accumulate Transform/Write, initializes/clears per-table accumulators on Restart, and includes all four timing fields in Progress(). The snapshot status API payload was updated to report row-based totals/read counts.

Poem

🐰 I hopped through code with tiny feet,
I timed each read and build complete,
Transform and write I watched with care,
Tables named so runtime can declare,
Now snapshots hum with timing sweet.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(snapshot): four-phase timing (A/B/C/D) per table' clearly and specifically describes the main change—implementing per-table, per-phase timing measurements.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing the four phases, changes to key components, and verification steps.
Linked Issues check ✅ Passed All primary objectives from issue #254 are met: CaptureResult gains Table/TableDone fields [#254], SnapshotCapturer measures ReadMs/BuildMs per batch [#254], Runtime measures TransformMs/WriteMs [#254], TableProgress includes four timing fields [#254], and SnapshotFinalizer interface added [#254] for per-table timing feedback.
Out of Scope Changes check ✅ Passed All changes align with issue #254 objectives. The modifications to CaptureResult, SnapshotCapturer, Runtime, and TableProgress directly support per-table timing. Changes to other capturers (CDC, replay) implement required interface methods as no-ops, which is appropriate per design. Server endpoint changes reflect removal of obsolete fields and addition of timing fields.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The per-table TransformMs/WriteMs currently only record the timings from the final batch (FinalizeTable is called once with the last batch’s measurements), so if the intent is to capture total per-table C/D time you likely want to accumulate these across all batches for a table instead of overwriting with the last batch.
  • In Runtime.Run you track batchTransformMs/batchWriteMs even when retries occur; if you intend to measure only plugin execution and not retry backoff/wait time, consider moving the timers inside the retry callback around the actual Transform/SinkWrite calls.
  • FinalizeTable identifies tables by string equality on FullName; if tables can be renamed or represented in different canonical forms, it may be more robust to pass and track a stable table identifier (e.g., index or struct reference) instead of a string lookup on every finalization.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The per-table TransformMs/WriteMs currently only record the timings from the final batch (FinalizeTable is called once with the last batch’s measurements), so if the intent is to capture total per-table C/D time you likely want to accumulate these across all batches for a table instead of overwriting with the last batch.
- In Runtime.Run you track batchTransformMs/batchWriteMs even when retries occur; if you intend to measure only plugin execution and not retry backoff/wait time, consider moving the timers inside the retry callback around the actual Transform/SinkWrite calls.
- FinalizeTable identifies tables by string equality on FullName; if tables can be renamed or represented in different canonical forms, it may be more robust to pass and track a stable table identifier (e.g., index or struct reference) instead of a string lookup on every finalization.

## Individual Comments

### Comment 1
<location path="internal/core/runtime.go" line_range="122-123" />
<code_context>
 			// Convert capture.Change to core.Change
 			changes := r.convertChanges(result.Changes)

+			// Track per-batch timing for C (Transform) and D (SinkWrite)
+			var batchTransformMs, batchWriteMs int64
+
 			// Transform phase with retry (independent timing measurement)
</code_context>
<issue_to_address>
**issue (bug_risk):** Per-table C/D timing currently only captures the last batch, not the full table time.

Phases A/B correctly accumulate per-table time in `SnapshotCapturer.Fetch` (`+= fetchMs` / `+= buildMs`), but for C/D you only measure a single batch (`batchTransformMs` / `batchWriteMs`) and then overwrite `tableTransformMs[i]` / `tableWriteMs[i]` in `FinalizeTable`. Given the field comments (“time spent in Transform per table” / “time spent in SinkWrite per table”), this is misleading: it reports only the last batch, not total per-table time. Either accumulate C/D timings across batches (e.g., maintain per-table running totals or make `FinalizeTable` add instead of overwrite), or rename/reword the fields/comments if you only intend to record the final batch’s timings.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread internal/core/runtime.go
- FinalizeTable now += accumulates rather than = overwrites
- Runtime.Run calls FinalizeTable on every batch (not just TableDone)
- This ensures C+D timing matches A+B behavior (all batches accumulate)
- Add trailing newline to capturer.go for Go style conformance

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/snapshot/capturer.go">

<violation number="1" location="internal/snapshot/capturer.go:293">
P1: Phase C/D table timing is underreported because only the last batch duration is persisted when finalizing a table.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread internal/snapshot/capturer.go Outdated
- Capturer interface now includes RecordTiming(table, transformMs, writeMs)
- SnapshotCapturer implements RecordTiming (accumulates per batch)
- CDC/Replay capturers implement RecordTiming as no-op
- Removed standalone SnapshotFinalizer interface
- Runtime.Run calls capturer.RecordTiming directly on every batch

Simplifies the design by keeping all capturer operations in one interface.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/core/capturer.go (1)

25-26: ⚡ Quick win

Clarify CaptureResult.Table semantics to match runtime behavior.

Line 26 says Table is only for the last batch, but runtime uses result.Table for per-batch timing attribution. Please align the comment/contract (e.g., “current batch table; TableDone marks final batch”) to avoid future regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/capturer.go` around lines 25 - 26, Update the comment on the
CaptureResult struct to reflect actual runtime semantics: change the Table field
comment to say it represents the table for the current batch (not only the last
batch) and clarify that TableDone is a boolean indicating that this batch is the
final batch for that table; ensure the comment around CaptureResult.Table and
CaptureResult.TableDone (symbols: CaptureResult.Table, CaptureResult.TableDone)
clearly states "current batch table; TableDone marks final batch" so future
readers align with runtime usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/core/runtime.go`:
- Around line 154-155: Timing is currently dropped when result.Table is empty
because capturer.RecordTiming(result.Table, batchTransformMs, batchWriteMs) uses
only result.Table; change this to use a stable table attribution by falling back
to the last-seen table when result.Table == "" (e.g., maintain a local lastTable
variable in the surrounding scope or add a field on the capturer to store
lastTable), update lastTable whenever result.Table is non-empty, and call
capturer.RecordTiming(effectiveTable, batchTransformMs, batchWriteMs) where
effectiveTable = result.Table != "" ? result.Table : lastTable so intermediate
C/D timing is accumulated per-table.

---

Nitpick comments:
In `@internal/core/capturer.go`:
- Around line 25-26: Update the comment on the CaptureResult struct to reflect
actual runtime semantics: change the Table field comment to say it represents
the table for the current batch (not only the last batch) and clarify that
TableDone is a boolean indicating that this batch is the final batch for that
table; ensure the comment around CaptureResult.Table and CaptureResult.TableDone
(symbols: CaptureResult.Table, CaptureResult.TableDone) clearly states "current
batch table; TableDone marks final batch" so future readers align with runtime
usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: de47391b-9ee4-4604-bd86-1749fc875e85

📥 Commits

Reviewing files that changed from the base of the PR and between 1fd3cf7 and 609bdb2.

📒 Files selected for processing (5)
  • internal/cdc/capturer.go
  • internal/core/capturer.go
  • internal/core/runtime.go
  • internal/replay/capturer.go
  • internal/snapshot/capturer.go
✅ Files skipped from review due to trivial changes (1)
  • internal/replay/capturer.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/snapshot/capturer.go

Comment thread internal/core/runtime.go
Comment on lines +154 to +155
// Record timing for every batch (accumulates C+D timing per table)
capturer.RecordTiming(result.Table, batchTransformMs, batchWriteMs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent dropping C/D timing when result.Table is empty.

At Line 155, timing is recorded every batch, but table attribution relies solely on result.Table. If that field is only populated on table-final batches, intermediate C/D timings will be lost from per-table totals.

Suggested fix
-			// Record timing for every batch (accumulates C+D timing per table)
-			capturer.RecordTiming(result.Table, batchTransformMs, batchWriteMs)
+			// Record timing for every batch (accumulates C+D timing per table)
+			tableForTiming := result.Table
+			if tableForTiming == "" && len(changes) > 0 {
+				tableForTiming = changes[0].Table
+			}
+			if tableForTiming != "" {
+				capturer.RecordTiming(tableForTiming, batchTransformMs, batchWriteMs)
+			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Record timing for every batch (accumulates C+D timing per table)
capturer.RecordTiming(result.Table, batchTransformMs, batchWriteMs)
// Record timing for every batch (accumulates C+D timing per table)
tableForTiming := result.Table
if tableForTiming == "" && len(changes) > 0 {
tableForTiming = changes[0].Table
}
if tableForTiming != "" {
capturer.RecordTiming(tableForTiming, batchTransformMs, batchWriteMs)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/runtime.go` around lines 154 - 155, Timing is currently dropped
when result.Table is empty because capturer.RecordTiming(result.Table,
batchTransformMs, batchWriteMs) uses only result.Table; change this to use a
stable table attribution by falling back to the last-seen table when
result.Table == "" (e.g., maintain a local lastTable variable in the surrounding
scope or add a field on the capturer to store lastTable), update lastTable
whenever result.Table is non-empty, and call
capturer.RecordTiming(effectiveTable, batchTransformMs, batchWriteMs) where
effectiveTable = result.Table != "" ? result.Table : lastTable so intermediate
C/D timing is accumulated per-table.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/core/runtime.go">

<violation number="1" location="internal/core/runtime.go:155">
P2: Per-batch C/D timing is not actually accumulated because `result.Table` is empty on most snapshot batches, so `RecordTiming` skips those batches.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Comment thread internal/core/runtime.go
}

// Record timing for every batch (accumulates C+D timing per table)
capturer.RecordTiming(result.Table, batchTransformMs, batchWriteMs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Per-batch C/D timing is not actually accumulated because result.Table is empty on most snapshot batches, so RecordTiming skips those batches.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/core/runtime.go, line 155:

<comment>Per-batch C/D timing is not actually accumulated because `result.Table` is empty on most snapshot batches, so `RecordTiming` skips those batches.</comment>

<file context>
@@ -151,12 +151,8 @@ func (r *Runtime) Run(ctx context.Context) error {
-				}
-			}
+			// Record timing for every batch (accumulates C+D timing per table)
+			capturer.RecordTiming(result.Table, batchTransformMs, batchWriteMs)
 
 			// Write batch log (after transformer, regardless of capturer type)
</file context>

Tip: Review your code locally with the cubic CLI to iterate faster.

yaitoo added 2 commits May 12, 2026 21:52
RecordTiming is now called every batch. SnapshotCapturer tracks
table completion internally and accumulates timing per table.
UI no longer needs done/processed counts - real-time timing fields
(ReadMs, BuildMs, TransformMs, WriteMs) are sufficient for monitoring.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/snapshot/capturer.go (2)

415-429: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Avoid re-locking c.mu on the query error path.

Line 426 calls markError, but this block already holds c.mu from Line 415. Because markError locks the same mutex again, the first query failure deadlocks Fetch.

Suggested fix
  c.mu.Lock()
  if c.stopped {
    c.mu.Unlock()
    if rows != nil {
      _ = rows.Close()
    }
    return &core.CaptureResult{NextCapturer: core.CapturerCDC}
  }
  if queryErr != nil {
    slog.Error("SnapshotCapturer: query failed",
      "table", c.currentTable, "offset", offset, "error", queryErr)
-   c.markError(fmt.Sprintf("table %s at offset %d: %v", c.currentTable, offset, queryErr))
+   c.lastError = fmt.Sprintf("table %s at offset %d: %v", c.currentTable, offset, queryErr)
+   c.pending = false
    c.tableIndex++
    c.rowOffset = 0
    c.mu.Unlock()
    return &core.CaptureResult{NextCapturer: core.CapturerSnapshot}
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/snapshot/capturer.go` around lines 415 - 429, The query-error branch
in Fetch currently holds c.mu and then calls c.markError which also attempts to
lock c.mu, causing a deadlock; to fix, release c.mu before calling c.markError
(or add a markErrorNoLock helper and call that while still holding the lock),
ensuring you still update c.tableIndex and c.rowOffset under the lock if needed
and close rows if non-nil; reference c.mu, Fetch, c.markError, c.tableIndex and
c.rowOffset when making the change.

472-498: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set TableDone on the terminal batch.

This result carries Table, but never marks the last batch as complete. That breaks the Runtime-side table-finalization contract described in the PR, so per-table phase C/D aggregation won't reliably fire when a table finishes.

Suggested fix
  result := &core.CaptureResult{
    Changes:      changes,
    BatchID:      batchCtx.BatchID,
    NextCapturer: core.CapturerSnapshot,
    Table:        table.FullName(),
+   TableDone:    isLastBatch,
  }
  return result

If empty tables also need finalization, return a zero-change completion result for the len(batchRows) == 0 case instead of exiting without any table-complete signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/snapshot/capturer.go` around lines 472 - 498, The terminal snapshot
batch must mark table completion: when isLastBatch is true set the returned
core.CaptureResult.TableDone = true (for the result built in the non-empty
branch where changes are created) and for the early-return case when
len(batchRows) == 0 return a zero-change core.CaptureResult that includes Table:
table.FullName(), NextCapturer: core.CapturerSnapshot, BatchID (use
core.NewBatchContext()), and TableDone = true so empty tables also emit the
table-complete signal; update the code paths around SnapshotCapturer, the
len(batchRows) == 0 return, and the result construction to include TableDone
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/snapshot/capturer.go`:
- Around line 415-429: The query-error branch in Fetch currently holds c.mu and
then calls c.markError which also attempts to lock c.mu, causing a deadlock; to
fix, release c.mu before calling c.markError (or add a markErrorNoLock helper
and call that while still holding the lock), ensuring you still update
c.tableIndex and c.rowOffset under the lock if needed and close rows if non-nil;
reference c.mu, Fetch, c.markError, c.tableIndex and c.rowOffset when making the
change.
- Around line 472-498: The terminal snapshot batch must mark table completion:
when isLastBatch is true set the returned core.CaptureResult.TableDone = true
(for the result built in the non-empty branch where changes are created) and for
the early-return case when len(batchRows) == 0 return a zero-change
core.CaptureResult that includes Table: table.FullName(), NextCapturer:
core.CapturerSnapshot, BatchID (use core.NewBatchContext()), and TableDone =
true so empty tables also emit the table-complete signal; update the code paths
around SnapshotCapturer, the len(batchRows) == 0 return, and the result
construction to include TableDone accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e63c24bf-7a81-4b06-a7d8-3578c6a5d7bb

📥 Commits

Reviewing files that changed from the base of the PR and between c4bc503 and 2c8f595.

📒 Files selected for processing (2)
  • cmd/app/server.go
  • internal/snapshot/capturer.go
💤 Files with no reviewable changes (1)
  • cmd/app/server.go

@cnlangzi
cnlangzi merged commit 94dcd97 into main May 13, 2026
5 checks passed
@cnlangzi
cnlangzi deleted the fix/snapshot-performance-monitoring-four-phase-timing branch May 13, 2026 02:03
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.

Snapshot Performance Monitoring - Four-Phase Timing

1 participant