Hydroscope

DBLog tap TUI — a live, watermark-level view of chunked CDC reconciliation.

Hydroscope running the showcase scenario — 12 seconds of chunk lifecycles

~12 seconds of --scenario showcase at 120 ms/event.

What you are looking at

DBLog bootstraps a consistent copy of a table with a snapshot that takes rows in small chunks, interleaved with the live CDC stream. Each chunk is bracketed by a pair of watermarks — LW (low watermark) and HW (high watermark) — written to and read back from the log itself. Any log event that arrives between LW and HW touching a pk the chunk selected wins over the snapshot: the log version is newer, the snapshot row is dropped from the refresh emit.

Hydroscope is a read-only observer that attaches to DBLog's educational tap endpoint (GET /api/v1/tap/stream) and shows the algorithm from the outside: every source-log event on the top-left, every post-reconciliation sink event on the bottom-left, and the live state of the active chunk's reconciler on the right.

Never enable the tap in production. The tap deliberately blocks the DBLog pump when the subscriber can't keep up — that's the whole point. Teaching machines only.

Build

cd ops/tap-tui
cargo build --release --bin hydroscope

Output: target/release/hydroscope. A single binary, no runtime dependencies beyond libc.

Run — demos & live

Offline / demo mode

Three built-in scenarios replay scripted event streams without touching a database. Same wire format as the live tap, so the UI rendering path is identical:

target/release/hydroscope --scenario showcase      # infinite, rich
target/release/hydroscope --scenario chunk42         # finite, 1 chunk, teaching
target/release/hydroscope --scenario chunk42-standby # chunk42 + standby pair
target/release/hydroscope --demo                     # alias for chunk42

See Scenarios below for what each one walks through.

Live mode

Omit --scenario / --demo and hydroscope connects to the DBLog tap:

target/release/hydroscope    # default: 127.0.0.1:8085
target/release/hydroscope --url http://host:port/api/v1/tap/stream

Requires the tap to be enabled in the DBLog runtime — see docs/OPERATION.md § 5.1.2 and docs/CONTROL_PLANE.md § 5.4 in the main repo. 503 tap_not_enabled surfaces as a clear disconnect banner with reconnect backoff. Run-id changes (process restart on the DBLog side) reset UI state.

Configuration (flags)

FlagDefaultEffect
--scenario <name> chunk42, chunk42-standby, showcase
--demo off Shorthand for --scenario chunk42
--slowdown <ms> 100 (showcase) · 350 (chunk42) · 0 (live) Sleep N ms between events. Higher = slower. Adjustable at runtime with n / m.
--step off Start in step mode — one event per space.
--url <URL> http://127.0.0.1:8085/api/v1/tap/stream Live mode tap endpoint.

Runtime keys

KeyEffect
q · Esc · Ctrl+CQuit
spaceIn step mode, advance one event; otherwise enter step mode.
sToggle step mode
nSlow down by 100 ms
mSpeed up by 100 ms (clamped to 0)
fFull speed (delay = 0)

Pace state is surfaced in the banner: full, N ms/event, or STEP (space=next).

UI anatomy

The screen is split 72% / 28% horizontally. Left column stacks SOURCE LOG (top) and SINK STREAM (bottom); right column is the RECONCILER sidebar. Six distinct areas, each shown below at its actual rendered size so you can read the content:

Title bar

row 1 · always present
Title bar: DBLOG · HYDROSCOPE, run id (first 13 chars), source id

Brand marker on the left, then run <id> (first 13 chars of the DBLog run UUID), source_id, and uptime t+HH:MM:SS.mmm. The rightmost cell (clipped in this narrow capture) is the window state label:

OPEN LW received, HW pending · CLOSING HW received, chunk not yet completed · SELECTED chunk selected but LW still pending · no chunk active.

Connection banner

row 2 · priority-ordered
Banner showing ● connected · demo://chunk-42 · request 17 · TABLE orders · ACTIVE · pace full

Priority order: standby > error > disconnect > connection state. When healthy, shows ● connected, queue fill (queue N/M (P%)), the latest request's id + scope + state, and current pace.

⚠ STANDBY and ✗ ERROR / ⚠ DISCONNECTED are the attention states — DISCONNECTED also shows uptime so you know how long we've been retrying.

Source log

top-left pane · 50% of the left column
Source log table with seq, LSN, op, table/pk, kind, note columns — showing wm-written, chunk-sel, cdc, and wm-received rows

Every tap event in the order it arrived. Columns: seq, t (relative to first event), LSN, kind, op, table / pk, note.

Event kinds: cdc, wm-written, wm-received, chunk-sel, chunk-drop, chunk-done. Colour maps to semantics below.

checkpoint.advanced events are producer-side bookkeeping — the TUI accepts them on the wire but intentionally drops them, so the source log stays focused on what drives the reconciler.

Strictly wire-derived — no cross-referencing between event streams. A collision surfaces as its own chunk-drop row on arrival.

Sink stream

bottom-left pane · 50% of the left column
Sink stream table with seq, t, LSN, op, table, pk, origin, badge columns — LOG and SELECT origins

What downstream consumers see after reconciliation. Columns: seq, t, LSN, op, table, pk, origin, badge.

LOG = CDC passthrough · SELECT = snapshot refresh row emitted on HW. Badge surfaces the in-window chunk id for LOG rows that landed inside a watermark window; it's empty otherwise (including for SELECT rows — the origin already says "snapshot refresh", and the active dump id is on the reconciler sidebar).

Reconciler sidebar

right column · 28% · full height
Reconciler sidebar showing LW token/lsn/latency, in-window cdc collisions + passthrough count, chunk buffer with excluded pks crossed out

Live state of the active chunk only. Flows top-to-bottom in algorithm time-order:

  1. pk [lo … hi] — chunk's pk range
  2. req / dump — request + dump identity
  3. ▼ LW — low-watermark token, LSN, latency
  4. in-window cdc — collisions listed (up to 3), passthrough count
  5. chunk buffer — pk range visualisation, excluded pks crossed out
  6. excluded (log won) — up to 6 excluded pks one-per-line
  7. △ HW — high-watermark token, LSN, latency
  8. emit-ready — refresh row count waiting on HW
  9. metadata footer — after / final / fp

Fixed-width sidebar — doesn't collapse or resize when the chunk lifecycle advances, so the source/sink panels stay visually anchored.

Footer

row -1 · always present
Footer showing keyboard shortcuts: q quit, space step/advance, s toggle step, n/m pace, f full speed

Keyboard shortcuts cheatsheet. See Runtime keys for the full reference.

Colour semantics

Colours carry consistent meaning across panels:

BLUE flowing / complete / healthy — CDC events, connected, LW received, HW on closed chunk
YELLOWanomaly / pending — collision triggers, HW pending, standby, excluded pk
CYAN process state — sink origin=SELECT, request lifecycle
RED hard signal — LW marker, error, disconnected banner
gray / dim chrome and orientation — labels, placeholders, "no data yet"

Scenarios

ScenarioShapeWhat it exercises
chunk42 Finite (~65 events) Single chunk, 2 collisions, 38 refresh rows. The algorithm's "hello world".
chunk42-standby Finite chunk42 plus a stream.standby / stream.resumed pair at the tail — exercises the backpressure banner.
showcase Infinite Scripted intro (4 chunks with increasing contention, standby interlude, final-chunk + request COMPLETED, non-fatal error), then procedural continuous traffic until quit.

Showcase intro, step by step

  1. Pre-request warmup: heartbeat + plain CDC + checkpoint
  2. Request 42 submitted (scope TABLE, app.orders) → ACTIVE
  3. Chunk 1 — 15 rows, 0 collisions (happy path)
  4. Plain-CDC gap (DELETE + INSERT outside any window)
  5. Chunk 2 — 15 rows, 3 collisions (one is a DELETE hit)
  6. stream.standby + stream.resumed (backpressure interlude)
  7. Chunk 3 — 20 rows, 5 collisions (heavy contention)
  8. Chunk 4 — 8 rows, 1 collision, final_chunk=true
  9. Request 42 COMPLETED
  10. Post-request plain CDC
  11. An error event (WatermarkSequenceException, non-fatal)
  12. Final heartbeat before the continuous loop starts

After the intro, the continuous loop emits deterministic (xorshift-seeded) random traffic — plain CDC bursts, chunks of 10–30 rows with 0–5 collisions each, heartbeats, checkpoints, occasional standby pairs and non-fatal errors — until you quit.

Tip: Start with --scenario chunk42 to see a full chunk lifecycle start-to-finish, then run --scenario showcase to see the pattern repeat at scale.

Troubleshooting

"terminal too narrow" / columns clipping

Hydroscope is designed around a ~120-column wide terminal. On narrower windows, the source-log and sink-stream tables drop rightmost columns first (kind, note, badge). The reconciler sidebar holds its content but tokens (UUIDs) and LSNs may clip mid-string.

503 tap_not_enabled

Live mode landing on a DBLog runtime whose tap is disabled. Enable dblog.tap.enabled=true in application.properties and restart. Disconnect banner retries automatically with backoff.

Pace feels wrong

Defaults: 100 ms/event for showcase, 350 ms/event for chunk42 (slow enough to read), 0 for live. Override with --slowdown <ms> at launch or n / m / f at runtime.