Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **writer/durability**: **async (non-blocking) recording** -- an opt-in that moves all chunk/journal disk I/O off the caller's (game) thread, so accepting a frame never waits on serialization, compression, `fsync`, or a chunk-boundary flush. New sink decorator **`AsyncSinkAdapter<InnerSink>`** (`sdk/include/vtx/writer/policies/sinks/async_sink_adapter.h`) turns `JournalFrame` / `SaveChunk` / `Close` into enqueue operations on a bounded FIFO that a single dedicated I/O worker drains in order, calling the real `ChunkedFileSink`. `OnSessionStart` / `JournalTiming` stay synchronous (session setup and the 'S' record must precede everything). Enabled through `WriterFacadeConfig`:
- `async_io` (default **off**) selects the decorated sink; `async_max_queue_frames` (0 -> `2 * chunk_max_frames`) bounds the queue **by item count** -- a full queue blocks the caller (degrading toward synchronous behavior; never drops a frame, never grows memory unbounded). (Byte-bounding is not implementable today: `FlatBuffersVtxPolicy::GetFrameSize()` returns 0 and journal payload sizes are only known after worker-side serialization.)
- `durable_writes` and `enable_recovery_journal` are now exposed at the facade (previously only on the internal sink config), for both synchronous and async recordings.
- **Ordering + crash-recovery are unchanged by construction**: one FIFO + one worker replays the exact synchronous on-disk sequence (data-before-journal, F-record contiguity, append-only journal + compaction), so `RepairReplayFile` needs no changes. What changes is a bounded **durability lag** -- a frame is crash-recoverable only once its item (or group-commit batch) is durable; a hard kill loses that not-yet-durable suffix, and recovery still yields a clean contiguous prefix. `Drain()` and `Stop()` are the zero-lag synchronization points.
- **Group commit**: the worker `fsync`s the recovery journal once per drained batch instead of once per frame, so a slow disk (HDD, ~10-20 ms/fsync) keeps up with 60 fps instead of the queue filling and async degenerating to blocking.
- **Failure protocol**: the first inner-sink I/O failure latches an error, discards the queue, and closes the file + journal handles WITHOUT a footer (releasing the Windows deny-write share so the partial `.vtx` and its journal are repair-ready immediately, even while the process keeps running). Every blocking wait -- a full-queue producer, `Drain()`, `Stop()`, the destructor -- is failure-aware and returns promptly. It surfaces as the new **`VtxErrorCode::SinkFailed`** on the next `TryRecordFrame` (via a zero-cost `if constexpr HasFailed()` hook on the writer core -- no interface change for existing sinks) and through the facade's new `GetLastError()`; `PipelineReport` counts it in a dedicated **`sink_failed`** field, kept separate from `validation_errors`.
- New facade surface on **`IVtxWriterFacade`**: `Drain()` (durability barrier -- blocks until every accepted frame is durable; **note: under async, `Flush()` only closes and enqueues the current chunk and is NO LONGER a durability barrier -- `Drain()` is**), `GetLastError()`, and `GetQueueDepth()` (backpressure telemetry).
- Supporting primitives (synchronous path byte-for-byte unchanged): `RecoveryJournal::AppendFrame` gains a `sync` flag plus `SyncNow()`, and `ChunkedFileSink` gains `JournalFrameBatched` / `SyncJournal` / `IsJournalActive` / `Good` / `AbortClose`.
- An enqueue issued after the I/O worker had already exited (e.g. recording after `Stop()`) could block forever waiting for room on a queue nobody drains any more; the worker now publishes its exit and every wait predicate honours it, so such a call returns immediately instead of deadlocking.
- Verified in `tests/writer/test_async_sink.cpp` (14 tests) and `tests/writer/test_crash_recovery.cpp`: order equivalence against an instrumented fake inner sink; async-vs-sync **byte identity** through the real sink (FlatBuffers + Protobuf); **no frame or chunk loss** across the durability x compression x journal x chunk-size x queue-cap matrix (frame count, chunk count and per-frame content hashes all compared against a synchronous recording); whole-replay validation of a clean async file; a 2000-frame stream through a one-item queue; worker start/join churn; `Drain()` interleaved under permanent backpressure; the failure protocol (SinkFailed surfacing + handle release); and async-ON variants of the crash-recovery E2E **plus the real `TerminateProcess` kill matrix** (durable / flush-only / mid-compaction / varied progress points), each recovering to a clean contiguous prefix identical to what a synchronous clean run would have produced.
- Race freedom checked under **ThreadSanitizer** (gcc-13, `VTX_SANITIZE=thread`): 97 writer/crash-recovery tests clean. The harness was itself validated by confirming TSan reports a deliberately injected race in the adapter.

## [0.4.0] - 2026-07-20

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ VTX is an open binary format for real-time per-frame state data, plus a C++20 SD
- **Both Protobuf and FlatBuffers.** SDK supports both backends out of the box. The file announces which in its magic bytes; readers auto-detect.
- **Live streaming transports.** Ingest frames live from an OS pipe (Windows named pipe, POSIX FIFO, stdin) or a WebSocket connection, and emit the `.vtx` byte stream over a TCP socket instead of writing to disk. Same writer API behind each transport -- file or network, sink-agnostic.
- **Crash-safe recording.** A write-ahead `.recovery` sidecar (checksummed, append-only, fsync'd per operation by default) makes a recording that dies before `Stop()` recoverable up to the last recorded frame -- exact per-frame timestamps included. `RepairReplayFile()` reconstructs the footer; a hours-long session that crashes is no longer lost.
- **Non-blocking recording.** Opt into `async_io` and chunk/journal disk I/O -- serialization, zstd, `fsync`, the chunk-boundary flush -- moves to a dedicated worker thread, so your capture loop never waits on the disk. Accept/reject stays synchronous and post-processors keep running on your thread; a bounded queue applies backpressure instead of dropping frames. The bytes on disk are identical to a synchronous recording, and crash recovery is unchanged.
- **Validation & structured diagnostics.** Validate a schema, entity, frame, or whole replay independently; strict recording rejects frames observably (bad game-time, unresolved entity type). Every failure is a structured `VtxError` -- code, severity, location, expected-vs-provided type -- so automation acts on data, not parsed log lines.
- **Engine-independent C++20.** No engine dependency. Language bindings wherever Protobuf or FlatBuffers exist (Python, Go, Rust, Java, JS).
- **Open.** Apache-2.0. Spec, reference reader, and tooling all in the repo.
Expand Down Expand Up @@ -56,6 +57,10 @@ writer->Flush();
writer->Stop();
```

#### Optional: keep the disk off your capture loop

Set `config.async_io = true` and the chunk/journal writes move to a worker thread -- `RecordFrame` no longer pays serialization, compression, `fsync`, or the chunk-boundary flush. The API is otherwise the same, with two things to know: `Flush()` stops being a durability barrier (it queues the write; the new `writer->Drain()` is the barrier), and if the disk cannot keep up the caller blocks on a bounded queue rather than losing frames. Full contract in [`docs/SDK_API.md`](docs/SDK_API.md).

#### Optional: one call with `WriteReplay`

If your frames come from an `IFrameDataSource`, `VTX::WriteReplay(config, source)` runs the whole pipeline -- create writer, drain the source, finalize -- and returns a `WriteReplayResult` (frames written / dropped, one warning per dropped frame, elapsed time). The schema can be supplied in memory too: set `config.schema_json_content` (raw JSON) or `config.schema_registry` (a pre-built `std::shared_ptr<VTX::SchemaRegistry>`) instead of `schema_json_path`.
Expand Down
7 changes: 5 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ Provides the shared type system, serialization infrastructure, and utilities use

Records live frame data into `.vtx` replay files (or streams the same bytes over a socket).

- **Facade**: `IVtxWriterFacade` — `RecordFrame()`, `TryRecordFrame()` (returns a `RecordResult`), `Flush()`, `Stop()`, `SetPostProcessor()`
- **Facade**: `IVtxWriterFacade` — `RecordFrame()`, `TryRecordFrame()` (returns a `RecordResult`), `Flush()`, `Stop()`, `SetPostProcessor()`, plus the async-I/O surface `Drain()` (durability barrier), `GetLastError()`, `GetQueueDepth()`
- **Finalization & strict recording**: each frame is validated (entity types resolve to schema structs), content-hashed (after schema fields + post-processor overrides), and **frozen** (stashed mutation handles revoked) before it enters the chunk pipeline. `TryRecordFrame()` makes rejection observable via `RecordResult` (carries a `VtxError`); the writer assigns a monotonic frame index; an opt-in `retain_finalized_snapshot` keeps the last finalized frame queryable via `GetLastFinalizedFrame()` / `FindEntity()`. `RecordPipeline::Run()` returns a `PipelineReport`
- **Factory**: `CreateFlatBuffersWriterFacade()` / `CreateProtobufWriterFacade()` (file output); `CreateFlatBuffersNetworkWriterFacade()` / `CreateProtobufNetworkWriterFacade()` (TCP socket output) — same `IVtxWriterFacade` behind both, so user code is sink-agnostic
- **One-call pipeline**: `WriteReplay(config, source, format)` (`vtx_write_replay.h`) creates the writer, drains an `IFrameDataSource`, finalizes, and returns a `WriteReplayResult` (frames written / dropped, one `VtxWarning` per dropped frame, elapsed time)
- **Config**: `WriterFacadeConfig` (file path) or `NetworkWriterFacadeConfig` (host + port) — chunk size, compression, and the schema supplied as a JSON path, an in-memory JSON string (`schema_json_content`), or a pre-built `SchemaRegistry` (`schema_registry`); `create_output_dirs` (default on) auto-creates missing parent directories of the output file
- **Config**: `WriterFacadeConfig` (file path) or `NetworkWriterFacadeConfig` (host + port) — chunk size, compression, and the schema supplied as a JSON path, an in-memory JSON string (`schema_json_content`), or a pre-built `SchemaRegistry` (`schema_registry`); `create_output_dirs` (default on) auto-creates missing parent directories of the output file; durability (`durable_writes`, `enable_recovery_journal`) and async I/O (`async_io`, `async_max_queue_frames`) are exposed on the file config
- **Policy**: Template-parameterized writer policies select FlatBuffers or Protobuf serialization at compile time
- **Sinks**: `ChunkedFileSink<Policy>` (file), `ChunkedNetworkSink<Policy>` (TCP stream) — both produce the **same `.vtx` byte sequence**, so a socket receiver only has to concatenate incoming bytes into a file to get a valid replay
- **Sink decorator**: `AsyncSinkAdapter<InnerSink>` (`async_sink_adapter.h`) wraps a sink so `JournalFrame` / `SaveChunk` / `Close` become enqueue operations on a bounded FIFO that a single I/O worker drains **in order** — same call sequence, therefore the same bytes and the same crash-recovery invariants. Session setup (`OnSessionStart` / `JournalTiming`) stays synchronous. Selected at runtime by `WriterFacadeConfig::async_io`, so it is just another policy: the writer core is unchanged
- **Data-source interface**: `IFrameDataSource` (`Initialize()` / `GetNextFrame()` / `GetExpectedTotalFrames()`). Concrete implementations: `samples/advance_write.cpp` (JSON / Protobuf / FlatBuffers from disk); `PipeFrameDataSource<Adapter>` (stdin, Windows named pipes, POSIX FIFOs — including a **server mode** that creates the pipe and waits, for independent game-injector-style external producers); `WebSocketFrameDataSource<Adapter>` (`ws://` + `wss://` with TLS). Both streaming sources share the `IFramePayloadAdapter` concept, so a single adapter plugs into either transport. A `SharedMemoryFrameDataSource` (zero-copy SPSC ring, pluggable `ISharedMemoryTransport`) is present in-tree but **experimental / WIP and not yet functional** — landed unintentionally, not a supported input path
- **Dependencies**: protobuf + flatbuffers (serialization), zstd (chunk compression), IXWebSocket + mbedTLS (WebSocket transport; hidden behind a PIMPL boundary in `websocket_client.cpp` — never leak into the public SDK headers)

Expand Down Expand Up @@ -91,6 +92,8 @@ Each module exposes a single abstract interface (`IVtxReaderFacade`, `IVtxWriter

## Threading Model

- **Writer (default)**: single-threaded. `RecordFrame()` runs the whole pipeline — validation, post-processor, serialization, chunk/journal I/O — on the caller's thread.
- **Writer async I/O** (`async_io = true`): exactly ONE extra thread per writer, owned by `AsyncSinkAdapter`. It is the only thread that touches the inner sink after session start; the caller thread only enqueues. Frame accept/reject, the post-processor, and the snapshot APIs all stay on the caller's thread, so the writer's single-threaded contract is unchanged from user code's point of view. Shared state is a mutex-guarded FIFO plus an atomic failure latch; backpressure blocks the producer on a bounded queue, and `Drain()` / `Stop()` are the synchronization points. Verified race-free under ThreadSanitizer.
- **Reader chunk loading**: `std::async` with `std::stop_token`. Up to 3 concurrent chunk loads. Chunks are loaded in priority order (active chunk first, then the window around it).
- **Chunk eviction**: Automatic when the sliding window moves. Eviction fires `OnChunkEvicted` on the `ReaderChunkState`.
- **Logger**: Thread-safe singleton. Sinks are called under a lock; formatting happens before the lock.
Expand Down
1 change: 1 addition & 0 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Ratio shrinks on big frames because hashing itself becomes non-trivial — but t
- **No competitor comparison.** We measure VTX against itself.
- **`items_per_second` in google/benchmark uses CPU time, not wall time.** Where wall ≫ CPU (async I/O), that metric overstates user-observable throughput. For customer-facing claims use the wall-time column.
- **One known fixture bug** — `BM_AccessorRandomWithinBucket` inflates its own counter 2× due to a duplicate-push in the shuffle setup. Flagged for a follow-up fix; everything else is trustworthy.
- **Synchronous writer only.** Every writer number here is the default (synchronous) sink, where `RecordFrame` pays serialization, compression, `fsync` and the chunk-boundary flush inline. They say nothing about `async_io = true`, which moves that work to an I/O worker thread. **No async figures are published yet**: the async-aware benchmark — per-`RecordFrame` latency histograms (p50/p99/max) measured *inside* the loop, excluding the `Stop()` drain — is not in the suite yet. `BM_WriterDurabilityTier` as it stands includes `Stop()` in its per-iteration time, so it cannot show the difference. Until that lands, treat the async win as *architectural* (the fsync and the boundary hitch leave the capture loop) rather than a measured number.

## Running benchmarks locally

Expand Down
4 changes: 3 additions & 1 deletion docs/POST_PROCESSING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ The writer is **single-threaded by design**: `RecordFrame()` is called sequentia

This is different from the reader-side processor model some other VTX features use (e.g. async chunk loads). The writer's simplicity is deliberate.

**Async I/O does not change any of this.** With `async_io = true` the writer still runs validation, the post-processor, and serialization on your calling thread -- only the chunk/journal disk writes move to the I/O worker, and that worker never touches a frame the processor can still see (it consumes an already-serialized copy). `Process` therefore keeps its caller-thread affinity: it is safe to read game state from it under async exactly as it is under the synchronous writer. Preserving this guarantee is precisely why the async cut was made between the writer and the sink, rather than moving the whole pipeline onto a worker.

## API at a glance

```cpp
Expand Down Expand Up @@ -324,7 +326,7 @@ Avoid resolving `PropertyKey<T>` per `Process()` call -- the lookup hashes by na

- **Re-serializing mutated bytes back to disk on the reader side.** Not this feature. Reader-side mutations would diverge from on-disk wire bytes -- documented contract.
- **Mutating raw byte spans.** `GetRawFrameBytes` returns on-disk truth by design.
- **Async processors.** `Process` is synchronous on the writer's calling thread. Long-running work blocks `RecordFrame`.
- **Async processors.** `Process` is synchronous on the writer's calling thread. Long-running work blocks `RecordFrame`. (Not to be confused with the writer's `async_io` option, which offloads only the sink's disk I/O -- it does not move `Process` off your thread, by design.)
- **Replay-level metadata emission API.** Processors that produce per-replay artefacts (KeyFrames, stats) expose them via their own getters -- the SDK does not add a metadata channel.

## See also
Expand Down
Loading
Loading