VLink  2.0.0
A high-performance communication middleware
bag_writer.h
Go to the documentation of this file.
1 /*
2  * Copyright (C) 2026 by Thun Lu. All rights reserved.
3  * Author: Thun Lu <thun.lu@zohomail.cn>
4  * Repo: https://github.com/thun-res/vlink
5  * _ __ __ _ __
6  * | | / / / / (_) ____ / /__
7  * | | / / / / / / / __ \ / //_/
8  * | |/ / / /___ / / / / / / / ,<
9  * |___/ /_____/ /_/ /_/ /_/ /_/|_|
10  *
11  * Licensed under the Apache License, Version 2.0 (the "License");
12  * you may not use this file except in compliance with the License.
13  * You may obtain a copy of the License at
14  *
15  * http://www.apache.org/licenses/LICENSE-2.0
16  *
17  * Unless required by applicable law or agreed to in writing, software
18  * distributed under the License is distributed on an "AS IS" BASIS,
19  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20  * See the License for the specific language governing permissions and
21  * limitations under the License.
22  */
23 
24 /**
25  * @file bag_writer.h
26  * @brief Abstract VLink bag recorder with split, compression, schema embedding and a global hook.
27  *
28  * @details
29  * @c BagWriter is the polymorphic base for VLink offline recording. It exposes a
30  * non-blocking @c push() entry point that enqueues serialised messages onto a private
31  * @c MessageLoop, which then persists them through the concrete backend. Two backends
32  * ship with VLink:
33  *
34  * - @c VDBWriter for SQLite-backed @c .vdb / @c .vdbx containers; default codec is LZAV
35  * for @c kCompressAuto and @c kCompressLzav selectors.
36  * - @c VCAPWriter for MCAP-format @c .vcap / @c .vcapx containers; @c kCompressAuto and
37  * @c kCompressZstd select Zstandard when Zstd support is compiled in.
38  *
39  * Writer state machine:
40  *
41  * @verbatim
42  * async_run() push() flush()/dtor
43  * +---------+ ----------> +-----------+ ---------> +---------+ ----------> +---------+
44  * | Open | | Running | <--------- | Pending | | Closed |
45  * +---------+ +-----------+ ack +---------+ +---------+
46  * ^ ^
47  * | |
48  * +--- split_by_size / split_by_time -- rotate --+
49  * @endverbatim
50  *
51  * On-disk layout produced by the writers:
52  *
53  * @verbatim
54  * +---------+----------------+---------------+----------------+--------+
55  * | Header | URL index | Schema index | Message stream | Footer |
56  * +---------+----------------+---------------+----------------+--------+
57  * tag url_metas schema_data payloads finalisation
58  * app
59  * timezone
60  * @endverbatim
61  *
62  * Feature highlights:
63  * - Asynchronous record path with optional synchronous @c immediate writes.
64  * - File splitting by byte size and/or by wall-clock interval.
65  * - Optional WAL mode for SQLite crash resilience.
66  * - URL-level loss reporting via @c set_url_loss().
67  * - Schema embedding through @c push_schema() for offline introspection.
68  * - Process-global writer triggered by the @c VLINK_BAG_PATH environment variable.
69  *
70  * @par Example
71  * @code
72  * vlink::BagWriter::Config cfg;
73  * cfg.compress = vlink::BagWriter::kCompressAuto;
74  * cfg.split_by_size = 1024LL * 1024LL * 512; // 512 MiB per split
75  *
76  * auto writer = vlink::BagWriter::create("/data/drive_log.vdb", cfg);
77  * writer->async_run();
78  *
79  * vlink::Frame frame;
80  * frame.timestamp = -1; // < 0 => writer auto-assigns from its clock (0 is verbatim)
81  * frame.url = "dds://camera/front";
82  * frame.ser_type = "demo.proto.Image";
83  * frame.schema_type = vlink::SchemaType::kProtobuf;
84  * frame.action_type = vlink::ActionType::kPublish;
85  * frame.data = bytes;
86  * writer->push(frame);
87  * @endcode
88  *
89  * @par Global writer
90  * @code
91  * // Set VLINK_BAG_PATH=/data/global.vdb before process launch.
92  * if (auto* gw = vlink::BagWriter::global_get(); gw != nullptr) {
93  * vlink::Frame frame;
94  * frame.timestamp = -1; // < 0 => auto-assign (0 would be recorded verbatim)
95  * frame.url = "intra://debug";
96  * frame.ser_type = "raw";
97  * frame.schema_type = vlink::SchemaType::kRaw;
98  * frame.action_type = vlink::ActionType::kPublish;
99  * frame.data = bytes;
100  * gw->push(frame);
101  * }
102  * @endcode
103  *
104  * @note @c push() is thread-safe. @c immediate=true bypasses the queue and writes on the
105  * caller's thread; this should be reserved for finalisation or test code because it
106  * can block long enough to violate real-time deadlines.
107  */
108 
109 #pragma once
110 
111 #include <chrono>
112 #include <cstdint>
113 #include <memory>
114 #include <mutex>
115 #include <string>
116 #include <string_view>
117 #include <unordered_map>
118 #include <unordered_set>
119 #include <vector>
120 
121 #include "../base/functional.h"
122 #include "../base/macros.h"
123 #include "../base/message_loop.h"
124 #include "../impl/types.h"
125 #include "./bag_plugin_interface.h"
126 
127 namespace vlink {
128 
129 class SchemaPluginInterface;
130 
131 /**
132  * @class BagWriter
133  * @brief Asynchronous VLink message recorder built on top of @c MessageLoop.
134  *
135  * @details
136  * Construct via @c create() (or directly) and call @c async_run() to start the recording
137  * thread, then push messages with @c push(). Concrete subclasses implement every virtual
138  * persistence operation; the base class owns the shared bookkeeping and the loop wiring.
139  */
141  public:
142  /**
143  * @brief Compression codec selector understood by the writer backends.
144  *
145  * | Value | Algorithm | Notes |
146  * | --------------- | --------- | ------------------------------------------------------ |
147  * | kCompressNone | none | Payloads stored as raw bytes |
148  * | kCompressAuto | backend | Uses the backend default (LZAV for VDB, Zstd for MCAP) |
149  * | kCompressZstd | Zstandard | Active for MCAP when Zstd support is available |
150  * | kCompressLz4 | LZ4 | Reserved selector; not currently used by built-ins |
151  * | kCompressLzav | LZAV | Active for SQLite-backed VDB recordings |
152  */
153  enum CompressType : uint8_t {
154  kCompressNone = 0, ///< Store payloads uncompressed.
155  kCompressAuto = 1, ///< Defer codec choice to the active backend.
156  kCompressZstd = 2, ///< Force Zstandard codec where supported.
157  kCompressLz4 = 3, ///< Reserved selector; no built-in writer emits LZ4 today.
158  kCompressLzav = 4, ///< Force LZAV codec where supported.
159  };
160 
161  /**
162  * @struct Config
163  * @brief Recording behaviour, split policy and resource budgets.
164  *
165  * @details
166  * Sizes are expressed in bytes and durations in milliseconds unless explicitly stated.
167  */
168  struct Config final {
169  std::string tag_name; ///< Optional tag stored in the bag header.
170  CompressType compress{CompressType::kCompressNone}; ///< Compression codec selector.
171  bool wal_mode{false}; ///< Enable SQLite WAL for crash resilience.
172  bool enable_limit{false}; ///< When true, evict oldest rows at the row/byte limit.
173  bool split_name_by_time{false}; ///< Append a timestamp suffix to split filenames.
174  bool sync_mode{false}; ///< Disable periodic cache-flush timer for VDB writes.
175  bool optimize_on_exit{false}; ///< Run VACUUM/OPTIMIZE while closing the file.
176  int64_t max_row_count{5'000'000'000LL}; ///< SQLite row cap; either evicts or fails new writes.
177  int64_t max_bytes_size{1024LL * 1024LL * 1024LL * 512LL}; ///< SQLite byte cap; either evicts or fails new writes.
178  int64_t split_by_size{1024LL * 1024LL * 1024LL * 1LL}; ///< Split threshold in bytes (0 disables).
179  int64_t split_by_time{0}; ///< Split interval in milliseconds (0 disables).
180  int64_t begin_time{0}; ///< Anchor (ms) used by time-based splits.
181  int64_t cache_size{1024LL * 1024LL * 4}; ///< VDB commit chunk / MCAP chunk size in bytes.
182  int64_t compress_start_size{128}; ///< Minimum payload size eligible for compression.
183  int64_t compress_level{3}; ///< Codec-specific compression level.
184  int64_t max_task_depth{20000}; ///< Maximum pending writes in the loop queue.
185  int64_t max_memory_size{1024LL * 1024LL * 1024LL * 2LL}; ///< Maximum in-memory cache size in bytes.
186  int64_t start_timestamp{0}; ///< Override for the wall-clock start timestamp (ms).
187  std::unordered_set<std::string> ignore_compress_urls; ///< URLs whose payloads must never be compressed.
188 
189  Config() {} // NOLINT(modernize-use-equals-default)
190  };
191 
192  /**
193  * @brief Notification fired when the writer rotates to a new split file.
194  *
195  * @details
196  * Called with the zero-based split index and the new file path. The @c before flag of
197  * @c register_split_callback() chooses whether the hook runs before or after the
198  * rotation is committed.
199  */
200  using SplitCallback = MoveFunction<void(int split_index, const std::string& split_filename)>;
201 
202  /**
203  * @brief Schema resolver used by the writer when a previously unseen URL is recorded.
204  *
205  * @details
206  * The writer passes the requested serialisation type together with a coarse schema
207  * family hint so that families sharing a single type name (e.g. Protobuf vs Arrow) can
208  * still be disambiguated.
209  */
210  using SchemaCallback = MoveFunction<SchemaData(const std::string& ser_type, SchemaType schema_type)>;
211 
212  /**
213  * @brief System clock alias used when formatting timestamps into split file names.
214  */
215  using SystemClock = std::chrono::time_point<std::chrono::system_clock, std::chrono::milliseconds>;
216 
217  /**
218  * @brief Builds the concrete writer matching the extension of @p path.
219  *
220  * @details
221  * Suffix dispatch: @c .vdb / @c .vdbx select @c VDBWriter, @c .vcap / @c .vcapx select
222  * @c VCAPWriter; other suffixes return @c nullptr. The returned writer is open but
223  * idle until @c async_run() starts its loop.
224  *
225  * @param path Output file path.
226  * @param config Recording configuration.
227  * @return Shared pointer to the new writer, or @c nullptr on unsupported suffix.
228  */
229  [[nodiscard]] static std::shared_ptr<BagWriter> create(const std::string& path, const Config& config = {});
230 
231  /**
232  * @brief Returns the cached writer for @p path, lazily creating and starting one.
233  *
234  * @details
235  * Looks up the process-wide writer registry. When no entry exists, a writer is built
236  * by @c create(), its loop is started with @c async_run(), and it is registered for
237  * reuse. The registry releases the entry automatically when the last shared owner
238  * goes away. Unsupported suffixes return @c nullptr and are not registered.
239  *
240  * @param path Output file path.
241  * @return Shared pointer to a started writer, or @c nullptr on unsupported suffix.
242  */
243  [[nodiscard]] static std::shared_ptr<BagWriter> filter_get(const std::string& path);
244 
245  /**
246  * @brief Returns the singleton writer driven by the @c VLINK_BAG_PATH environment variable.
247  *
248  * @details
249  * On first call, the writer is created from @c VLINK_BAG_PATH and started. Returns
250  * @c nullptr when the environment variable is absent or carries an unsupported suffix.
251  *
252  * @return Raw pointer to the global writer, or @c nullptr.
253  */
255 
256  /**
257  * @brief Constructs the base writer and opens the output file.
258  *
259  * @details
260  * The recording loop is not yet running; call @c async_run() before any @c push().
261  *
262  * @param path Output file path.
263  * @param config Recording configuration.
264  */
265  explicit BagWriter(const std::string& path, const Config& config = {});
266 
267  /**
268  * @brief Halts the loop, flushes pending writes and closes the file.
269  */
270  virtual ~BagWriter(); // NOLINT(modernize-use-override)
271 
272  /**
273  * @brief Attaches a custom frame-forwarding plugin to this writer.
274  *
275  * @details
276  * The plugin's @c on_write() hook runs for every frame before it is persisted; it re-emits each
277  * frame through @c do_callback(), and may transcode, drop, fan out, or buffer and reorder frames by
278  * their true data-plane time (a sliding-window reorder) before they reach the bag. The writer
279  * supplies the record sink via @c BagPluginInterface::register_callback() and binds the plugin with
280  * @c BagPluginInterface::Direction::kWrite. Passing @c nullptr detaches and clears the previous
281  * plugin's sink.
282  *
283  * @param plugin_interface Plugin instance, or @c nullptr to detach the current binding.
284  *
285  * @see clear_plugin_interface() for the named equivalent of passing @c nullptr.
286  */
287  virtual void bind_plugin_interface(const std::shared_ptr<BagPluginInterface>& plugin_interface);
288 
289  /**
290  * @brief Detaches the currently bound plugin, if any.
291  *
292  * @details
293  * Convenience wrapper equivalent to @c bind_plugin_interface(nullptr): flushes the bound plugin's
294  * pending frames, clears its record sink and drops the binding. Safe to call when no plugin is
295  * bound (in which case it is a no-op).
296  */
297  virtual void clear_plugin_interface();
298 
299  /**
300  * @brief Installs a hook fired around split rotation.
301  *
302  * @param callback Receives the new split index and the new file path.
303  * @param before When true, the hook fires before the new file is opened; otherwise after.
304  */
305  virtual void register_split_callback(SplitCallback&& callback, bool before) = 0;
306 
307  /**
308  * @brief Installs the resolver invoked when an unseen serialisation type is recorded.
309  *
310  * @param callback Function mapping (ser_type, schema_type) to @c SchemaData.
311  */
312  virtual void register_schema_callback(SchemaCallback&& callback) = 0;
313 
314  /**
315  * @brief Embeds a schema descriptor into the bag for downstream introspection.
316  *
317  * @param schema_data Schema descriptor to persist.
318  * @param immediate When true, performs a synchronous merge on the caller's thread.
319  * @return @c true on success; @c false when an immediate merge fails or a queued merge task
320  * cannot be enqueued.
321  */
322  virtual bool push_schema(const SchemaData& schema_data, bool immediate = false) = 0;
323 
324  /**
325  * @brief Records a single frame to the bag.
326  *
327  * @details
328  * Enqueues a write task onto the recording loop; the actual disk write happens on the loop thread.
329  * When @c frame.timestamp is negative the writer assigns a recording-relative timestamp from its
330  * elapsed clock; a non-negative @c frame.timestamp (including @c 0) is recorded verbatim.
331  *
332  * When a plugin is bound via @c bind_plugin_interface(), the frame is handed to the plugin's
333  * @c on_write() hook, which re-emits it (possibly transcoded, dropped, fanned out or reordered)
334  * through the writer's record sink into the concrete @c record() implementation. Because the
335  * plugin may emit asynchronously, the return value is then the assigned timestamp rather than a
336  * per-frame record result; a frame the plugin drops simply never reaches @c record().
337  *
338  * @param frame Frame to record. @c url must not be empty; @c timestamp < 0 requests auto-assign.
339  * @param immediate When true, bypasses the queue and writes synchronously (honoured for frames a
340  * plugin emits synchronously from @c on_write()).
341  * @return Assigned timestamp in microseconds, or a negative value on immediate validation/write failure.
342  */
343  int64_t push(const Frame& frame, bool immediate = false);
344 
345  /**
346  * @brief Streaming shorthand for @c push(frame).
347  *
348  * @details
349  * Enqueues @p frame on the recording loop exactly like @c push(frame, false) and returns the
350  * writer so calls can be chained, e.g. @c *writer << frame_a << frame_b. The asynchronous
351  * (non-immediate) record path is always used; reach for @c push(frame, true) when a synchronous
352  * write is required. The per-frame timestamp that @c push() returns is not surfaced here; instead,
353  * a negative @c push() result (e.g. empty URL, or a synchronous record failure forwarded by a
354  * bound plugin) latches the @c fail() state so failures are observable without inspecting every
355  * return value.
356  *
357  * @param frame Frame to record; @c url must not be empty, @c timestamp < 0 requests auto-assign.
358  * @return Reference to @c *this for chaining.
359  */
360  BagWriter& operator<<(const Frame& frame);
361 
362  /**
363  * @brief Streaming shorthand for @c push_schema(schema_data).
364  *
365  * @details
366  * Embeds @p schema_data through the asynchronous @c push_schema(schema_data, false) path and
367  * returns the writer for chaining, e.g. @c *writer << schema << frame. A @c false result -- the
368  * merge task could not be enqueued, or a bound backend rejected it -- latches the @c fail() state.
369  *
370  * @param schema_data Schema descriptor to persist.
371  * @return Reference to @c *this for chaining.
372  */
373  BagWriter& operator<<(const SchemaData& schema_data);
374 
375  /**
376  * @brief Returns whether a previous @c operator<< observed a write failure.
377  *
378  * @details
379  * Latches when @c operator<<(const Frame&) sees a negative @c push() result or
380  * @c operator<<(const SchemaData&) sees a @c false @c push_schema() result. Plain @c push() /
381  * @c push_schema() never alter this flag -- callers of those keep using their return values.
382  * Cleared by @c clear().
383  */
384  [[nodiscard]] bool fail() const noexcept;
385 
386  /**
387  * @brief Reports whether the streaming write state is still good (no latched failure).
388  *
389  * @details
390  * Returns @c true while no write failure has been latched, so @c if (*writer << frame) tests the
391  * post-write state.
392  */
393  explicit operator bool() const noexcept;
394 
395  /**
396  * @brief Clears a latched @c fail() state so streaming writes can resume being observed.
397  */
398  void clear() noexcept;
399 
400  /**
401  * @brief Returns the backend-specific "dump in progress" flag.
402  */
403  [[nodiscard]] virtual bool is_dumping() const = 0;
404 
405  /**
406  * @brief Returns whether split mode is currently in effect.
407  *
408  * @return @c true when the bag uses a splittable multi-file container
409  * (e.g. a @c .vdbx / @c .vcapx suffix), in which case
410  * @c split_by_size / @c split_by_time control the rotation timing;
411  * @c false otherwise, regardless of the @c split_by_* values.
412  */
413  [[nodiscard]] virtual bool is_split_mode() const = 0;
414 
415  /**
416  * @brief Returns the zero-based index of the active split file.
417  *
418  * @return Active split index, or 0 outside split mode.
419  */
420  [[nodiscard]] virtual int get_split_index() const = 0;
421 
422  /**
423  * @brief Records the expected loss ratio for @p url as bag metadata.
424  *
425  * @details
426  * Loss values feed offline diagnostics so that intentional drops can be distinguished
427  * from unexpected loss.
428  *
429  * @param url Topic URL.
430  * @param loss Loss ratio; values greater than 1.0 are normalised to -1.
431  */
432  virtual void set_url_loss(const std::string& url, double loss);
433 
434  protected:
435  virtual int64_t record(const Frame& frame, bool immediate) = 0;
436 
437  virtual int64_t get_record_timestamp() const = 0;
438 
439  std::string convert_recorded_url(const std::string& url) const;
440 
441  std::vector<std::string> recorded_urls_for_origin(const std::string& url) const;
442 
443  std::string recover_recorded_url(const std::string& url) const;
444 
445  void get_url_meta(const std::string& url, const std::string& ser, int& url_index, int& ser_index) const;
446 
447  void get_url_meta(int url_index, int ser_index, std::string& url, std::string& ser) const;
448 
449  std::mutex& sample_mutex();
450 
451  std::unordered_map<std::string, double>& url_loss_map_ref();
452 
453  std::unordered_map<std::string, double>& total_url_loss_map_ref();
454 
455  static const std::string& get_default_tag_name();
456 
457  static const std::string& get_default_app_name();
458 
459  static SchemaPluginInterface* get_schema_interface();
460 
461  static int32_t get_default_timezone_diff();
462 
463  static std::string_view convert_action(ActionType type);
464 
465  static std::string get_format_date(SystemClock* current = nullptr, bool file_format = false);
466 
467  void flush_plugin();
468 
469  void detach_plugin();
470 
471  private:
472  void learn_recorded_url(const std::string& origin_url, const std::string& recorded_url);
473 
474  struct Impl;
475  std::unique_ptr<Impl> impl_;
476 
478 };
479 
480 } // namespace vlink
Unified plugin contract for rewriting bag traffic on both playback (read) and recording (write).
#define VLINK_EXPORT
Definition: macros.h:81
#define VLINK_DISALLOW_COPY_AND_ASSIGN(classname)
Deletes the copy constructor and copy-assignment operator of classname.
Definition: macros.h:174