VLink  2.1.0
A high-performance communication middleware
trigger_recorder.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 trigger_recorder.h
26  * @brief Event-data-recorder engine: a rolling in-memory ring of every live topic, dumped to a bag on demand.
27  *
28  * @details
29  * @c TriggerRecorder is a self-contained data-plane engine (no RPC, no config parsing -- those belong to the
30  * caller). It discovers every topic on the bus, subscribes to the raw @c Bytes of each through a
31  * caller-supplied @c RawSubFactory, and keeps a rolling per-URL ring holding the most recent pre-trigger
32  * window. @c dump() persists the @b pre + @b post window around the trigger instant to a bag file, rotating
33  * old files. This is the dashcam / EDR pattern: the ring is always recording, the trigger decides what to
34  * persist.
35  *
36  * @verbatim
37  * discovery --new URL--> RawSubFactory (caller TU) --> RawSub --Bytes--> [per-URL rolling rings]
38  * |
39  * dump(): serialized, runs on the recorder loop |
40  * v
41  * trigger plugin on_dump_finished() <-- dump_dir bag (.vdb|.vcap) <-- BagWriter <-- [bag plugin? reorder]
42  * @endverbatim
43  *
44  * @par Life cycle
45  * The recorder is a @c MessageLoop: the constructor validates the @c Config and acquires every fallible
46  * resource (creates @c Config::dump_dir and constructs the discovery viewer), throwing on failure.
47  * @c async_run() starts discovery + buffering; wait for @c on_begin() to complete (e.g.
48  * @c invoke_task([](){}).wait()) before calling @c dump(). @c quit() stops the loop and abandons a dump still
49  * waiting for its post window; wait for @c is_dumping() to become false first when a dump must be preserved.
50  *
51  * @par Two distinct plugin roles (never conflate them)
52  * - A @b bag plugin (@c BagPluginInterface, supplied via @c bind_bag_interface()) sits @e inside the
53  * write path: its @c on_write() re-emits frames reordered by the true @b data-plane time parsed from each
54  * payload. Without it frames are written in @b capture-time (arrival) order.
55  * - A @b trigger plugin (@c TriggerPluginInterface, via @c bind_trigger_interface()) observes the
56  * recorder @e life cycle -- @c on_dump_finished() is the upload / archive hook. It never rewrites frames.
57  *
58  * @par Per-URL windows
59  * Each URL may override the global default @b pre / @b post window (milliseconds before / after the trigger),
60  * plus a max packet size, a per-URL byte cap and @c only_front / @c only_back restrictions. A global URL
61  * whitelist / blacklist selects which topics participate.
62  *
63  * @par Retention model (constant retention)
64  * Every enabled URL retains @c pre_u + @c max_post_all + @c 2*retention_guard of history (@c max_post_all =
65  * the largest @b post across all enabled URLs), so the ingest hot path needs no "is a trigger active" branch:
66  *
67  * @verbatim
68  * pre_u post_u
69  * |<---------------->|<---------------->| dump(T) accepted at T, written after the
70  * ring [==:==================T==================:==] largest effective post of the selected URLs
71  * T-pre_u-guard T+post_u+guard plus retention_guard (immediately when that
72  * selected at acceptance; frames sliced at write post is zero); the guard cushions both
73  * time from the guard-padded ring coverage window boundaries
74  * @endverbatim
75  *
76  * @note The subscriber callback (data into the ring) is the hot path: it runs on the transport dispatch
77  * thread(s), takes only a per-URL lock, copies the payload once with @c Bytes::deep_copy and is amortized
78  * O(1); one callback may evict multiple expired or over-limit entries.
79  * @warning A single URL with a large @b post raises the retention -- and memory -- of @e every URL.
80  * @warning A reordering bag plugin still copies and buffers part of the window until @c flush(); frames emitted
81  * downstream are synchronous and do not accumulate in an additional bag-writer queue.
82  * @warning @c busy_skip_data drops data while the bag writer is active, leaving time holes for later triggers.
83  * @warning With @c destroy_on_offline, offline buffers kept for an in-flight dump can push peak memory above
84  * @c max_cache_size.
85  *
86  * @par Usage
87  * @code
88  * vlink::TriggerRecorder::Config config;
89  * config.dump_dir = "/data/edr";
90  * config.default_pre_ms = 15'000;
91  * config.default_post_ms = 0;
92  *
93  * vlink::TriggerRecorder::UrlConfig camera;
94  * camera.pre_ms = 15'000; // pre=15s
95  * camera.post_ms = 0; // post=0
96  * config.url_overrides["dds://camera/front"] = camera;
97  *
98  * vlink::TriggerRecorder recorder(config, [](const std::string& url, vlink::InitType type) {
99  * return vlink::TriggerRecorder::RawSub::create_shared(url, type);
100  * });
101  * recorder.async_run();
102  * recorder.invoke_task([]() {}).wait(); // wait for on_begin() so dump() is accepted
103  * // ... later, on an external event ...
104  * vlink::TriggerRecorder::TriggerParams params;
105  * params.reason = "hard-brake";
106  * recorder.dump(params);
107  * @endcode
108  */
109 
110 #pragma once
111 
112 #include <cstdint>
113 #include <limits>
114 #include <memory>
115 #include <string>
116 #include <string_view>
117 #include <unordered_map>
118 #include <unordered_set>
119 #include <vector>
120 
121 #include "../base/message_loop.h"
122 #include "../subscriber.h"
123 #include "./discovery_viewer.h"
124 
125 namespace vlink {
126 
127 class BagPluginInterface;
128 class TriggerPluginInterface;
129 
130 /**
131  * @class TriggerRecorder
132  * @brief @c MessageLoop-based rolling in-memory recorder that dumps a pre/post window to a bag on trigger.
133  *
134  * @details
135  * Construct with a @c Config and @c RawSubFactory, call @c async_run(), then wait for @c on_begin() to complete
136  * (for example with @c invoke_task([](){}).wait()) before using @c dump(). Dumps are serialised and execute on
137  * the recorder loop. Wait for @c is_dumping() to become false before shutdown when an accepted dump must be
138  * preserved; @c quit() abandons a dump that is still waiting for its post-trigger window.
139  */
141  public:
142  /**
143  * @brief Raw byte subscriber owned by the recorder for one discovered URL.
144  */
146 
147  /**
148  * @brief Caller-side constructor for raw subscribers.
149  *
150  * @details
151  * The factory must return a fresh subscriber for @p url using the supplied @p type. It may apply caller-side
152  * transport properties that must precede @c init(), but must not initialize or start listening; the recorder
153  * applies getter semantics, loss tracking, schema metadata and discovery settings before it calls @c init() and
154  * @c listen(). The callable runs synchronously on the discovery-viewer thread and therefore must be short,
155  * non-blocking and must not re-enter this recorder.
156  *
157  * Keeping construction in the caller's translation unit is significant: the transport modules linked by the
158  * caller propagate their @c VLINK_SUPPORT_* definitions there, allowing the header-only URL dispatcher to select
159  * those linked backends.
160  */
161  using RawSubFactory = Function<std::shared_ptr<RawSub>(const std::string& url, InitType type)>;
162 
163  /**
164  * @brief Maximum accepted pre / post / retention-guard window length in milliseconds.
165  *
166  * @details
167  * Chosen so that the largest retention sum, @c pre + @c max_post_all + @c 2*retention_guard (four terms,
168  * each at most this bound), still converts to microseconds without overflowing @c int64_t. @c Config
169  * values and per-trigger @c TriggerParams windows beyond this bound are rejected; control-plane frontends
170  * (e.g. @c vlink-trigger) validate user input against the same constant.
171  */
172  static constexpr int64_t kMaxWindowMs = std::numeric_limits<int64_t>::max() / 4000;
173 
174  /**
175  * @enum OverflowPolicy
176  * @brief What to do when a byte cap (per-URL @c max_size or global @c max_cache_size) would be exceeded.
177  *
178  * @details
179  * Eviction is always local to the URL receiving the incoming frame: even when the @b global cap is the one
180  * exceeded, @c kCoverOldest only reclaims space from that URL's own ring, so pressure from one URL never
181  * evicts another URL's buffered history. When the ingesting URL's ring cannot free enough space, the
182  * incoming frame is dropped.
183  */
184  enum OverflowPolicy : uint8_t {
185  kCoverOldest = 0, ///< Evict the oldest buffered frame(s) to make room for the newest.
186  kDropNewest = 1, ///< Discard the incoming frame and keep the existing buffer.
187  };
188 
189  /**
190  * @enum FileType
191  * @brief On-disk container format for the dumped bag.
192  */
193  enum FileType : uint8_t {
194  kVdb = 0, ///< SQLite-backed VDB container (@c .vdb).
195  kVcap = 1, ///< MCAP container (@c .vcap).
196  };
197 
198  /**
199  * @struct UrlConfig
200  * @brief Per-URL overrides; any field left negative falls back to the matching @c Config default.
201  */
202  struct UrlConfig final {
203  int64_t pre_ms{-1}; ///< Pre-trigger window in ms; <0 uses @c Config::default_pre_ms.
204  int64_t post_ms{-1}; ///< Post-trigger window in ms; <0 uses @c Config::default_post_ms.
205  int64_t max_packet_size{-1}; ///< Drop packets larger than this many bytes; <0 uses default, 0 disables.
206  int64_t max_size{-1}; ///< Per-URL ring byte cap; <0 uses default, 0 disables.
207  bool only_front{false}; ///< Record only the pre-trigger side for this URL.
208  bool only_back{false}; ///< Record only the post-trigger side for this URL.
209 
210  UrlConfig() {} // NOLINT(modernize-use-equals-default)
211  };
212 
213  /**
214  * @struct Config
215  * @brief Recorder-wide configuration; passed once to the constructor and read-only afterwards.
216  */
217  struct Config final {
218  std::string dump_dir; ///< Output directory; empty => {tmp}/vlink-trigger.
219  FileType file_type{kVdb}; ///< Bag container format.
220  int64_t default_pre_ms{15'000}; ///< Default pre-trigger window in ms.
221  int64_t default_post_ms{0}; ///< Default post-trigger window in ms.
222  int64_t default_max_packet_size{4LL * 1024 * 1024}; ///< Default per-packet byte limit in bytes (0 = unlimited).
223  int64_t default_max_size{0}; ///< Default per-URL ring byte cap (0 = unlimited).
224  int64_t max_cache_size{2LL * 1024 * 1024 * 1024}; ///< Global ring byte cap across all URLs.
225  int64_t retention_guard_ms{500}; ///< Extra retention margin to absorb dump-timer jitter.
226  int max_dump_file_count{10}; ///< Rotation cap; only auto-named dumps trigger dump_dir rotation.
227  bool enable_compress{false}; ///< Compress the dumped bag.
228  bool busy_skip_data{false}; ///< Drop incoming data while a bag is being written.
229  bool destroy_on_offline{false}; ///< Destroy offline subscribers; an in-flight dump keeps their data.
230  OverflowPolicy overflow{kDropNewest}; ///< Byte-cap overflow policy.
231  int64_t sleep_interval{4LL * 1024 * 1024}; ///< Dump input throttle: sleep after this many bytes submitted.
232  int64_t sleep_time_ms{0}; ///< Dump input-throttle sleep duration in ms (0 disables).
233  DiscoveryViewer::FilterType discovery_filter{DiscoveryViewer::kFilterAvailable}; ///< Discovery filter.
234  std::vector<std::string> whitelist; ///< If non-empty, only these exact URLs are recorded.
235  std::vector<std::string> blacklist; ///< These exact URLs are never recorded.
236  std::unordered_map<std::string, UrlConfig> url_overrides; ///< Per-URL window / limit overrides.
237 
238  Config() {} // NOLINT(modernize-use-equals-default)
239  };
240 
241  /**
242  * @struct TriggerParams
243  * @brief Per-trigger parameters; a pure data struct with no RPC or protobuf dependency.
244  */
245  struct TriggerParams final {
246  std::string reason; ///< Human-readable trigger reason; stored as the bag tag.
247  std::string name_hint; ///< Optional base name; a numeric suffix is added rather than replacing an existing bag.
248  std::string out_file; ///< Explicit output path; when empty a name under @c dump_dir is generated.
249  int64_t pre_ms{-1}; ///< Per-trigger pre window; <0 uses each URL's configured pre (may only shrink it).
250  int64_t post_ms{-1}; ///< Per-trigger post window; <0 uses each URL's configured post (may only shrink it).
251  std::unordered_set<std::string> whitelist; ///< Exact URL whitelist; empty means all URLs pass this stage.
252  std::unordered_set<std::string> blacklist; ///< Exact URL blacklist applied after @c whitelist.
253  std::string filter_str; ///< Secondary comma/space-separated substring filter.
254  bool black_mode{false}; ///< False keeps substring matches; true drops them.
255 
256  TriggerParams() {} // NOLINT(modernize-use-equals-default)
257  };
258 
259  /**
260  * @brief Builds the recorder and acquires every fallible resource; the loop is not running yet.
261  *
262  * @details
263  * Validates the configuration and factory, creates @c Config::dump_dir and constructs the discovery viewer.
264  * Buffering begins only after @c async_run().
265  *
266  * @param config Recorder-wide configuration, copied and validated internally.
267  * @param factory Factory that constructs a fresh, uninitialized subscriber for each discovered URL.
268  * @throw Exception::RuntimeError When the configuration or factory is invalid, @c dump_dir cannot be created,
269  * or discovery setup fails.
270  */
271  TriggerRecorder(const Config& config, RawSubFactory&& factory);
272 
273  /**
274  * @brief Requests quit and joins the recorder loop thread.
275  */
276  ~TriggerRecorder() override;
277 
278  /**
279  * @brief Requests a dump of the pre/post window around the current instant.
280  *
281  * @details
282  * Non-blocking: it timestamps the trigger, rejects the call if a dump is already in flight, and enqueues the
283  * actual capture / reorder / write onto the recorder loop. When the selected URLs have a positive effective
284  * post window, execution is delayed by their largest effective post plus @c retention_guard_ms; otherwise it is
285  * enqueued immediately. The dump completes asynchronously. The set of participating URLs is selected and
286  * frozen when the call is accepted: topics discovered afterwards do not contribute to this dump, and a topic
287  * going offline (@c Config::destroy_on_offline) still contributes its already-buffered window. Calling @c quit()
288  * does not drain a dump that is still waiting for its post-trigger window.
289  *
290  * @param params Optional per-trigger overrides (reason, file name, shrunk windows).
291  * @return @c true when the dump was accepted and enqueued; @c false for an invalid window, before @c on_begin()
292  * completes, when stopped or already dumping, or when the dump task cannot be enqueued.
293  */
294  bool dump(const TriggerParams& params = {});
295 
296  /**
297  * @brief Requests a dump and returns its selected output path when accepted.
298  *
299  * @param params Per-trigger overrides.
300  * @param out_file Selected path on success; cleared when the request is rejected.
301  * @return @c true when the dump was accepted and @p out_file was set.
302  */
303  bool dump(const TriggerParams& params, std::string& out_file);
304 
305  /**
306  * @brief Reports whether a dump is currently in flight.
307  *
308  * @return @c true while a trigger's capture / write is running.
309  */
310  [[nodiscard]] bool is_dumping() const noexcept;
311 
312  /**
313  * @brief Binds the @b trigger plugin notified across the recorder's life cycle and dump pipeline.
314  *
315  * @details
316  * This is the @b post-dump behaviour plugin, distinct from the bag reorder plugin bound by
317  * @c bind_bag_interface(). Its hooks (see @c TriggerPluginInterface) fire as the recorder starts /
318  * stops, on each trigger, and around each dump -- most importantly @c on_dump_finished() once a bag is
319  * written, the place to upload or archive it. It never rewrites frames. Passing @c nullptr detaches the
320  * current plugin. Bind before @c async_run() or after the recorder has stopped; binding while it is running
321  * is rejected so one recorder run always has one stable lifecycle observer.
322  *
323  * @param trigger_interface Trigger plugin interface instance to bind, or @c nullptr to detach.
324  */
325  void bind_trigger_interface(const std::shared_ptr<TriggerPluginInterface>& trigger_interface);
326 
327  /**
328  * @brief Detaches the trigger plugin (equivalent to @c bind_trigger_interface(nullptr)).
329  */
330  void clear_trigger_interface();
331 
332  /**
333  * @brief Binds the @b bag reorder plugin applied inside the write path of every dump.
334  *
335  * @details
336  * This is the @b data-plane reorder plugin, distinct from the trigger plugin bound by
337  * @c bind_trigger_interface(). The recorder attaches it to the internal @c BagWriter of each dump via
338  * @c BagWriter::bind_bag_interface(); its @c on_write() hook parses the true data-plane time out of each
339  * payload and re-emits frames reordered by that time before they are persisted. The host owns plugin loading
340  * and lifetime, then supplies the resulting interface here. Passing @c nullptr detaches it, so dumps fall back
341  * to capture-time order. Bind before @c async_run() or after the recorder has stopped.
342  *
343  * @param bag_interface Bag reorder plugin interface instance to bind, or @c nullptr to detach.
344  */
345  void bind_bag_interface(const std::shared_ptr<BagPluginInterface>& bag_interface);
346 
347  /**
348  * @brief Detaches the bag reorder plugin (equivalent to @c bind_bag_interface(nullptr)).
349  */
350  void clear_bag_interface();
351 
352  protected:
353  void on_begin() override;
354 
355  void on_end() override;
356 
357  private:
358  struct UrlBuffer;
359  struct DumpJob;
360  struct Impl;
361 
362  void handle_data(UrlBuffer& url_buffer, const Bytes& data);
363 
364  std::shared_ptr<UrlBuffer> build_url_buffer(const DiscoveryViewer::Info& info);
365 
366  std::shared_ptr<RawSub> deactivate_url_buffer(UrlBuffer& url_buffer);
367 
368  void recompute_retention();
369 
370  void handle_discovery(const std::vector<DiscoveryViewer::Info>& list);
371 
372  void sweep_evict();
373 
374  void finish_dump(DumpJob& job);
375 
376  void finish_dump_locked(DumpJob& job);
377 
378  void notify_dump_failed(const DumpJob& job, std::string_view error);
379 
380  void do_dump(DumpJob& job);
381 
382  std::unique_ptr<Impl> impl_;
383 
385 };
386 
387 } // namespace vlink
Live aggregator of VLink endpoint announcements emitted by DiscoveryReporter.
#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