VLink  2.1.0
A high-performance communication middleware
bag_writer.h
浏览该文件的文档.
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  * @c push() entry point, which either enqueues serialised messages onto a private
31  * @c MessageLoop or persists them synchronously according to @c Config::sync_mode.
32  * Two backends 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  * Default asynchronous writer state machine:
40  *
41  * @verbatim
42  * async_run() push() close()/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  * - Writer-wide asynchronous or synchronous record policy selected by @c Config::sync_mode.
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  * writer->wait_for_idle();
88  * writer->quit();
89  * writer->wait_for_quit();
90  * writer->close();
91  * if (writer->fail()) { handle_recording_error(); }
92  * @endcode
93  *
94  * @par Global writer
95  * @code
96  * // Set VLINK_BAG_PATH=/data/global.vdb before process launch.
97  * if (auto* gw = vlink::BagWriter::global_get(); gw != nullptr) {
98  * vlink::Frame frame;
99  * frame.timestamp = -1; // < 0 => auto-assign (0 would be recorded verbatim)
100  * frame.url = "intra://debug";
101  * frame.ser_type = "raw";
102  * frame.schema_type = vlink::SchemaType::kRaw;
103  * frame.action_type = vlink::ActionType::kPublish;
104  * frame.data = bytes;
105  * gw->push(frame);
106  * }
107  * @endcode
108  *
109  * @note @c push() is thread-safe. @c Config::sync_mode selects synchronous writes for the
110  * writer's entire lifetime; otherwise writes are queued on the recording loop.
111  */
112 
113 #pragma once
114 
115 #include <chrono>
116 #include <cstdint>
117 #include <memory>
118 #include <mutex>
119 #include <string>
120 #include <string_view>
121 #include <unordered_map>
122 #include <unordered_set>
123 #include <vector>
124 
125 #include "../base/functional.h"
126 #include "../base/macros.h"
127 #include "../base/message_loop.h"
128 #include "../impl/types.h"
129 
130 namespace vlink {
131 
132 class BagPluginInterface;
133 class SchemaPluginInterface;
134 
135 /**
136  * @class BagWriter
137  * @brief Asynchronous VLink message recorder built on top of @c MessageLoop.
138  *
139  * @details
140  * Construct via @c create() (or directly) and call @c async_run() to start the recording
141  * thread, then push messages with @c push(). Concrete subclasses implement every virtual
142  * persistence operation; the base class owns the shared bookkeeping and the loop wiring.
143  */
145  public:
146  /**
147  * @brief Compression codec selector understood by the writer backends.
148  *
149  * | Value | Algorithm | Notes |
150  * | --------------- | --------- | ------------------------------------------------------ |
151  * | kCompressNone | none | Payloads stored as raw bytes |
152  * | kCompressAuto | backend | Uses the backend default (LZAV for VDB, Zstd for MCAP) |
153  * | kCompressZstd | Zstandard | Active for MCAP when Zstd support is available |
154  * | kCompressLz4 | LZ4 | Reserved selector; not currently used by built-ins |
155  * | kCompressLzav | LZAV | Active for SQLite-backed VDB recordings |
156  */
157  enum CompressType : uint8_t {
158  kCompressNone = 0, ///< Store payloads uncompressed.
159  kCompressAuto = 1, ///< Defer codec choice to the active backend.
160  kCompressZstd = 2, ///< Force Zstandard codec where supported.
161  kCompressLz4 = 3, ///< Reserved selector; no built-in writer emits LZ4 today.
162  kCompressLzav = 4, ///< Force LZAV codec where supported.
163  };
164 
165  /**
166  * @struct Config
167  * @brief Recording behaviour, split policy and resource budgets.
168  *
169  * @details
170  * Sizes are expressed in bytes and durations in milliseconds unless explicitly stated.
171  */
172  struct Config final {
173  std::string tag_name; ///< Optional tag stored in the bag header.
174  CompressType compress{CompressType::kCompressNone}; ///< Compression codec selector.
175  bool wal_mode{false}; ///< Enable SQLite WAL for crash resilience.
176  bool enable_limit{false}; ///< When true, evict oldest rows at the row/byte limit.
177  bool split_name_by_time{false}; ///< Append a timestamp suffix to split filenames.
178  bool sync_mode{false}; ///< Write synchronously and disable the VDB periodic cache-flush timer.
179  bool optimize_on_exit{false}; ///< Run VACUUM/OPTIMIZE while closing the file.
180  int64_t max_row_count{5'000'000'000LL}; ///< SQLite row cap; either evicts or fails new writes.
181  int64_t max_bytes_size{1024LL * 1024LL * 1024LL * 512LL}; ///< SQLite byte cap; either evicts or fails new writes.
182  int64_t split_by_size{1024LL * 1024LL * 1024LL * 1LL}; ///< Split threshold in bytes (0 disables).
183  int64_t split_by_time{0}; ///< Split interval in milliseconds (0 disables).
184  int64_t begin_time{0}; ///< Anchor (ms) used by time-based splits.
185  int64_t cache_size{1024LL * 1024LL * 4}; ///< VDB commit chunk / MCAP chunk size in bytes.
186  int64_t compress_start_size{128}; ///< Minimum payload size eligible for compression.
187  int64_t compress_level{3}; ///< Codec-specific compression level.
188  int64_t max_task_depth{20000}; ///< Maximum pending writes in the loop queue.
189  int64_t max_memory_size{1024LL * 1024LL * 1024LL * 2LL}; ///< Maximum in-memory cache size in bytes.
190  int64_t start_timestamp{0}; ///< Override for the wall-clock start timestamp (ms).
191  std::unordered_set<std::string> ignore_compress_urls; ///< URLs whose payloads must never be compressed.
192 
193  Config() {} // NOLINT(modernize-use-equals-default)
194  };
195 
196  /**
197  * @brief Notification fired when the writer rotates to a new split file.
198  *
199  * @details
200  * Called with the zero-based split index and the new file path. The @c before flag of
201  * @c register_split_callback() chooses whether the hook runs before or after the
202  * rotation is committed.
203  */
204  using SplitCallback = MoveFunction<void(int split_index, const std::string& split_filename)>;
205 
206  /**
207  * @brief Schema resolver used by the writer when a previously unseen URL is recorded.
208  *
209  * @details
210  * The writer passes the requested serialisation type together with a coarse schema
211  * family hint so that families sharing a single type name (e.g. Protobuf vs Arrow) can
212  * still be disambiguated.
213  */
214  using SchemaCallback = MoveFunction<SchemaData(const std::string& ser_type, SchemaType schema_type)>;
215 
216  /**
217  * @brief System clock alias used when formatting timestamps into split file names.
218  */
219  using SystemClock = std::chrono::time_point<std::chrono::system_clock, std::chrono::milliseconds>;
220 
221  /**
222  * @brief Builds the concrete writer matching the extension of @p path.
223  *
224  * @details
225  * Suffix dispatch: @c .vdb / @c .vdbx select @c VDBWriter, @c .vcap / @c .vcapx select
226  * @c VCAPWriter; other suffixes return @c nullptr. The returned writer is open immediately;
227  * asynchronous writers need @c async_run(), while synchronous writers do not.
228  *
229  * @param path Output file path.
230  * @param config Recording configuration.
231  * @return Shared pointer to the new writer, or @c nullptr on unsupported suffix.
232  */
233  [[nodiscard]] static std::shared_ptr<BagWriter> create(const std::string& path, const Config& config = {});
234 
235  /**
236  * @brief Returns the cached writer for @p path, lazily creating and starting one.
237  *
238  * @details
239  * Looks up the process-wide writer registry. When no entry exists, a writer is built
240  * by @c create(), its loop is started with @c async_run(), and it is registered for
241  * reuse. The registry releases the entry automatically when the last shared owner
242  * goes away. Unsupported suffixes return @c nullptr and are not registered.
243  *
244  * @param path Output file path.
245  * @return Shared pointer to a started writer, or @c nullptr on unsupported suffix.
246  */
247  [[nodiscard]] static std::shared_ptr<BagWriter> filter_get(const std::string& path);
248 
249  /**
250  * @brief Returns the singleton writer driven by the @c VLINK_BAG_PATH environment variable.
251  *
252  * @details
253  * On first call, the writer is created from @c VLINK_BAG_PATH and started. Returns
254  * @c nullptr when the environment variable is absent or carries an unsupported suffix.
255  *
256  * @return Raw pointer to the global writer, or @c nullptr.
257  */
259 
260  /**
261  * @brief Constructs the base writer and opens the output file.
262  *
263  * @details
264  * The recording loop is not yet running; call @c async_run() when @c Config::sync_mode is false.
265  * A synchronous writer performs frame, schema and plugin-output writes on the calling thread and
266  * does not require a recording-loop thread.
267  *
268  * @param path Output file path.
269  * @param config Recording configuration.
270  */
271  explicit BagWriter(const std::string& path, const Config& config = {});
272 
273  /**
274  * @brief Halts the loop, flushes pending writes and closes the file.
275  */
276  virtual ~BagWriter(); // NOLINT(modernize-use-override)
277 
278  /**
279  * @brief Attaches a custom frame-forwarding plugin to this writer.
280  *
281  * @details
282  * The plugin's @c on_write() hook runs for every frame before it is persisted; it re-emits each
283  * frame through @c do_callback(), and may transcode, drop, fan out, or buffer and reorder frames by
284  * their true data-plane time (a sliding-window reorder) before they reach the bag. The writer
285  * supplies the record sink via @c BagPluginInterface::register_callback() and binds the plugin with
286  * @c BagPluginInterface::Direction::kWrite. Passing @c nullptr detaches and clears the previous
287  * plugin's sink.
288  *
289  * @param bag_interface Plugin interface instance, or @c nullptr to detach the current binding.
290  *
291  * @see clear_bag_interface() for the named equivalent of passing @c nullptr.
292  */
293  virtual void bind_bag_interface(const std::shared_ptr<BagPluginInterface>& bag_interface);
294 
295  /**
296  * @brief Detaches the currently bound plugin, if any.
297  *
298  * @details
299  * Convenience wrapper equivalent to @c bind_bag_interface(nullptr): flushes the bound plugin's
300  * pending frames, clears its record sink and drops the binding. Safe to call when no plugin is
301  * bound (in which case it is a no-op).
302  */
303  virtual void clear_bag_interface();
304 
305  /**
306  * @brief Installs a hook fired around split rotation.
307  *
308  * @param callback Receives the new split index and the new file path.
309  * @param before When true, the hook fires before the new file is opened; otherwise after.
310  */
311  virtual void register_split_callback(SplitCallback&& callback, bool before) = 0;
312 
313  /**
314  * @brief Installs the resolver invoked when an unseen serialisation type is recorded.
315  *
316  * @param callback Function mapping (ser_type, schema_type) to @c SchemaData.
317  */
318  virtual void register_schema_callback(SchemaCallback&& callback) = 0;
319 
320  /**
321  * @brief Embeds a schema descriptor into the bag for downstream introspection.
322  *
323  * The operation follows @c Config::sync_mode: synchronous writers merge on the caller's thread;
324  * asynchronous writers enqueue the merge on the recording loop.
325  *
326  * @param schema_data Schema descriptor to persist.
327  * @return @c true on success; @c false when a synchronous merge fails or an asynchronous merge
328  * task cannot be enqueued.
329  */
330  virtual bool push_schema(const SchemaData& schema_data) = 0;
331 
332  /**
333  * @brief Records a single frame to the bag.
334  *
335  * @details
336  * The write follows the mode fixed at construction: @c Config::sync_mode writes on the caller's
337  * thread; otherwise a task is enqueued on the recording loop.
338  * Once accepted, an asynchronous frame is not evicted to admit a later frame.
339  * When @c frame.timestamp is negative the writer assigns a recording-relative timestamp from its
340  * elapsed clock; a non-negative @c frame.timestamp (including @c 0) is recorded verbatim.
341  *
342  * When a plugin is bound via @c bind_bag_interface(), the frame is handed to the plugin's
343  * @c on_write() hook, which re-emits it (possibly transcoded, dropped, fanned out or reordered)
344  * through the writer's record sink into the concrete @c record() implementation. Because the
345  * plugin may emit asynchronously, the return value is then the assigned timestamp rather than a
346  * per-frame record result; a frame the plugin drops simply never reaches @c record().
347  *
348  * @param frame Frame to record. @c url must not be empty; @c timestamp < 0 requests auto-assign.
349  * @return Assigned timestamp in microseconds, or a negative value on validation/write failure or when an
350  * asynchronous write cannot be queued (for example, because a task or memory limit was reached).
351  */
352  int64_t push(const Frame& frame);
353 
354  /**
355  * @brief Streaming shorthand for @c push(frame).
356  *
357  * @details
358  * Records @p frame according to @c Config::sync_mode and returns the writer so calls can be chained,
359  * e.g. @c *writer << frame_a << frame_b. The per-frame timestamp that @c push() returns is not surfaced; instead,
360  * a negative @c push() result (e.g. an empty URL, a queue or memory-limit rejection, or a synchronous
361  * record failure forwarded by a bound plugin) latches the @c fail() state so failures are observable
362  * without inspecting every return value.
363  *
364  * @param frame Frame to record; @c url must not be empty, @c timestamp < 0 requests auto-assign.
365  * @return Reference to @c *this for chaining.
366  */
367  BagWriter& operator<<(const Frame& frame);
368 
369  /**
370  * @brief Streaming shorthand for @c push_schema(schema_data).
371  *
372  * @details
373  * Embeds @p schema_data according to @c Config::sync_mode and returns the writer for chaining,
374  * e.g. @c *writer << schema << frame. A @c false result -- the
375  * merge task could not be enqueued, or a bound backend rejected it -- latches the @c fail() state.
376  *
377  * @param schema_data Schema descriptor to persist.
378  * @return Reference to @c *this for chaining.
379  */
380  BagWriter& operator<<(const SchemaData& schema_data);
381 
382  /**
383  * @brief Returns whether a stream operation, deferred backend write or finalisation has failed.
384  *
385  * @details
386  * Latches when a stream insertion is rejected, a concrete backend cannot persist an accepted asynchronous
387  * frame or schema, or @c close() cannot finalise the bag. Callers may wait for the queue to become idle and
388  * then query this method; call @c close() first when close-time metadata, footer or manifest failures must
389  * also be observed. Synchronous callers continue to use the return value from @c push() or @c push_schema().
390  * Cleared by @c clear().
391  */
392  [[nodiscard]] bool fail() const noexcept;
393 
394  /**
395  * @brief Reports whether the streaming write state is still good (no latched failure).
396  *
397  * @details
398  * Returns @c true while no write failure has been latched, so @c if (*writer << frame) tests the
399  * post-write state.
400  */
401  explicit operator bool() const noexcept;
402 
403  /**
404  * @brief Clears a latched @c fail() state so streaming writes can resume being observed.
405  */
406  void clear() noexcept;
407 
408  /**
409  * @brief Returns the backend-specific "dump in progress" flag.
410  */
411  [[nodiscard]] virtual bool is_dumping() const = 0;
412 
413  /**
414  * @brief Returns whether split mode is currently in effect.
415  *
416  * @return @c true when the bag uses a splittable multi-file container
417  * (e.g. a @c .vdbx / @c .vcapx suffix), in which case
418  * @c split_by_size / @c split_by_time control the rotation timing;
419  * @c false otherwise, regardless of the @c split_by_* values.
420  */
421  [[nodiscard]] virtual bool is_split_mode() const = 0;
422 
423  /**
424  * @brief Returns the zero-based index of the active split file.
425  *
426  * @return Active split index, or 0 outside split mode.
427  */
428  [[nodiscard]] virtual int get_split_index() const = 0;
429 
430  /**
431  * @brief Records the expected loss ratio for @p url as bag metadata.
432  *
433  * @details
434  * Loss values feed offline diagnostics so that intentional drops can be distinguished
435  * from unexpected loss.
436  *
437  * @param url Topic URL.
438  * @param loss Loss ratio; values greater than 1.0 are normalised to -1.
439  */
440  virtual void set_url_loss(const std::string& url, double loss);
441 
442  protected:
443  virtual int64_t record(const Frame& frame, int64_t timestamp) = 0;
444 
445  virtual int64_t get_record_timestamp() const = 0;
446 
447  public:
448  /**
449  * @brief Finalizes the backend file (final commit, metadata, footer) and latches any failure.
450  *
451  * @details
452  * Idempotent; invoked automatically at destruction. Callers that must verify the close-time
453  * writes call it explicitly and then query @c fail() while the writer is still alive. It is not
454  * synchronised against the recording loop. After producers have stopped, detach any bound write plugin with
455  * @c clear_bag_interface() so its buffered tail is emitted while the loop still accepts tasks; then call
456  * @c wait_for_idle(), @c quit() and @c wait_for_quit() before calling @c close() from another thread.
457  *
458  * @note Declared after the original virtual interface to preserve its vtable slot ordering.
459  */
460  virtual void close();
461 
462  /**
463  * @brief Formats a wall-clock timestamp with millisecond precision.
464  *
465  * @param current Time point to format; @c nullptr formats the current system time.
466  * @param file_format When true, produces the file-name-safe form @c YYYY-MM-DD_hh-mm-ss-mmm shared by
467  * generated bag names; otherwise the log form @c YYYY/MM/DD hh:mm:ss:mmm.
468  * @return Formatted timestamp string.
469  */
470  static std::string get_format_date(SystemClock* current = nullptr, bool file_format = false);
471 
472  protected:
473  std::string convert_recorded_url(const std::string& url) const;
474 
475  std::vector<std::string> recorded_urls_for_origin(const std::string& url) const;
476 
477  std::string recover_recorded_url(const std::string& url) const;
478 
479  void get_url_meta(const std::string& url, const std::string& ser, int& url_index, int& ser_index) const;
480 
481  void get_url_meta(int url_index, int ser_index, std::string& url, std::string& ser) const;
482 
483  std::mutex& sample_mutex();
484 
485  std::unordered_map<std::string, double>& url_loss_map_ref();
486 
487  std::unordered_map<std::string, double>& total_url_loss_map_ref();
488 
489  static const std::string& get_default_tag_name();
490 
491  static const std::string& get_default_app_name();
492 
493  static SchemaPluginInterface* get_schema_interface();
494 
495  static int32_t get_default_timezone_diff();
496 
497  static std::string_view convert_action(ActionType type);
498 
499  void flush_plugin();
500 
501  void detach_plugin();
502 
503  bool post_persistent_task(Callback&& callback);
504 
505  /**
506  * @brief Latches the writer failure state from a concrete backend.
507  */
508  void set_fail() noexcept;
509 
510  private:
511  void learn_recorded_url(const std::string& origin_url, const std::string& recorded_url);
512 
513  struct Impl;
514  std::unique_ptr<Impl> impl_;
515 
517 };
518 
519 } // namespace vlink
#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