Skip to main content

xlog_cuda/device_runtime/
logging.rs

1//! [`LoggingResource`] — telemetry decorator for any
2//! [`DeviceMemoryResource`].
3//!
4//! Wraps an inner resource and records every `allocate`,
5//! `deallocate`, and `reap_pending` call (success and failure) into
6//! a [`LoggingSink`]. The decorator is fully transparent to allocator
7//! semantics: it forwards every method, never alters the inner
8//! result, and never panics or returns an error caused by the sink
9//! itself. Logging failure is recorded into a per-resource diagnostic
10//! counter (visible via [`LoggingResource::dropped_records`]) and
11//! otherwise silenced — losing a log line must not corrupt
12//! allocation correctness.
13//!
14//! Sink design
15//! -----------
16//! [`LoggingSink`] is a tiny trait with a single `emit` method that
17//! returns `Result<(), SinkError>`. The default in-memory sink
18//! ([`InMemorySink`]) buffers records in a `Mutex<Vec<LogRecord>>`
19//! and is the test workhorse — it lets unit tests assert the exact
20//! sequence of records without filesystem dependencies. A future
21//! `CsvFileSink` or `RingBufferSink` slots in without touching the
22//! decorator.
23//!
24//! Record contents
25//! ---------------
26//! Every emitted [`LogRecord`] carries:
27//!   * `action` — Allocate / Deallocate / ReapPending
28//!   * `device_ordinal`
29//!   * `stream_id` — present for Allocate / Deallocate (the block's
30//!     alloc_stream); absent for ReapPending which spans streams.
31//!   * `ptr` / `bytes` / `tag` / `generation` — present when the
32//!     operation has a corresponding [`DeviceBlock`] reference.
33//!   * `thread_id` — `std::thread::current().id()` rendered as u64
34//!     for portability.
35//!   * `order_counter` — monotonic per-process u64. Strictly
36//!     increasing across all sinks, all threads, all resources.
37//!   * `timestamp_nanos` — `SystemTime::now()` since UNIX epoch in
38//!     nanoseconds. Wall-clock; not monotonic. Use `order_counter`
39//!     for ordering, `timestamp_nanos` for human-readable spans.
40//!   * `result` — Ok or short error tag string.
41//!
42//! Decorator does NOT capture the inner resource's pre-call state
43//! (e.g., bytes_outstanding before allocate) — that would require
44//! locking the inner resource an extra time and is not part of the
45//! v0.6 telemetry contract. Callers that need pre/post diffs read
46//! `bytes_outstanding` themselves.
47
48use std::fmt;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::sync::Mutex;
51use std::time::{SystemTime, UNIX_EPOCH};
52
53use super::resource::{
54    Access, AllocTag, BlockId, DeviceBlock, DeviceMemoryResource, Generation,
55    ResourceBudgetSnapshot, ResourceError, ResourceResult, StreamId,
56};
57
58/// Action recorded in a [`LogRecord`]. Distinct variants for the
59/// three inner methods so consumers can filter without parsing the
60/// result message.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum LogAction {
63    Allocate,
64    Deallocate,
65    ReapPending,
66}
67
68impl fmt::Display for LogAction {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            LogAction::Allocate => f.write_str("allocate"),
72            LogAction::Deallocate => f.write_str("deallocate"),
73            LogAction::ReapPending => f.write_str("reap_pending"),
74        }
75    }
76}
77
78/// Result status as recorded in a log entry. Successful operations
79/// are recorded as `Ok`; failed ones carry the error variant tag
80/// plus an optional short message. We keep the message bounded so a
81/// runaway driver-error string cannot blow up the sink buffer.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum LogResult {
84    Ok,
85    Err { kind: &'static str, message: String },
86}
87
88impl LogResult {
89    /// Convert a [`ResourceResult`] outcome into a `LogResult`,
90    /// truncating overlong messages to keep records bounded.
91    pub fn from_result<T>(r: &ResourceResult<T>) -> Self {
92        match r {
93            Ok(_) => LogResult::Ok,
94            Err(e) => LogResult::Err {
95                kind: classify_error(e),
96                message: truncate(format!("{}", e), 256),
97            },
98        }
99    }
100}
101
102fn classify_error(e: &ResourceError) -> &'static str {
103    match e {
104        ResourceError::OutOfBudget { .. } => "OutOfBudget",
105        ResourceError::Driver(_) => "Driver",
106        ResourceError::StreamMisuse(_) => "StreamMisuse",
107        ResourceError::UseAfterFree { .. } => "UseAfterFree",
108        ResourceError::OutOfBounds { .. } => "OutOfBounds",
109    }
110}
111
112fn truncate(mut s: String, cap: usize) -> String {
113    if s.len() > cap {
114        // String::truncate panics if `cap` is not on a UTF-8 char
115        // boundary. Error messages can include non-ASCII payloads
116        // (driver strings, user tags, etc.), so walk back to the
117        // previous boundary before truncating. This preserves the
118        // decorator's "never panics" guarantee — the logging path
119        // must not crash the allocator.
120        let mut end = cap;
121        while end > 0 && !s.is_char_boundary(end) {
122            end -= 1;
123        }
124        s.truncate(end);
125        s.push('…');
126    }
127    s
128}
129
130/// A single allocation-log entry. Values are owned/`Copy` so the
131/// record is `Clone + Send + Sync` and trivially serializable.
132#[derive(Clone, Debug)]
133pub struct LogRecord {
134    pub action: LogAction,
135    pub device_ordinal: u32,
136    pub stream_id: Option<StreamId>,
137    pub ptr: Option<u64>,
138    pub bytes: Option<usize>,
139    pub tag: Option<AllocTag>,
140    pub generation: Option<Generation>,
141    pub thread_id: u64,
142    pub order_counter: u64,
143    pub timestamp_nanos: u128,
144    pub result: LogResult,
145}
146
147/// Process-wide monotonic counter used for `LogRecord::order_counter`.
148/// Cross-resource, cross-thread strict order — useful for
149/// reconstructing the exact emission sequence in tests with multiple
150/// resources composed.
151static ORDER_COUNTER: AtomicU64 = AtomicU64::new(1);
152
153fn next_order_counter() -> u64 {
154    ORDER_COUNTER.fetch_add(1, Ordering::Relaxed)
155}
156
157fn now_nanos() -> u128 {
158    SystemTime::now()
159        .duration_since(UNIX_EPOCH)
160        .map(|d| d.as_nanos())
161        .unwrap_or(0)
162}
163
164fn current_thread_id_u64() -> u64 {
165    // `ThreadId` does not expose its inner u64 on stable. Hash the
166    // Debug representation instead — stable for the lifetime of the
167    // thread, distinct across threads, and fits in u64.
168    let s = format!("{:?}", std::thread::current().id());
169    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
170    for b in s.as_bytes() {
171        h ^= *b as u64;
172        h = h.wrapping_mul(0x100_0000_01b3);
173    }
174    h
175}
176
177/// Sink-side error. Returned by [`LoggingSink::emit`] when the sink
178/// cannot accept a record (full ring buffer, IO error, etc.). The
179/// decorator catches this and increments
180/// [`LoggingResource::dropped_records`]; it does **not** propagate
181/// the error to the allocator caller.
182#[derive(Debug)]
183pub enum SinkError {
184    /// The sink is closed or full and refused the record.
185    Refused(String),
186    /// IO-backed sinks may surface a wrapped IO error here.
187    Io(String),
188}
189
190impl fmt::Display for SinkError {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        match self {
193            SinkError::Refused(m) => write!(f, "log sink refused record: {}", m),
194            SinkError::Io(m) => write!(f, "log sink io error: {}", m),
195        }
196    }
197}
198
199impl std::error::Error for SinkError {}
200
201/// Trait for log destinations. Implementations are required to be
202/// thread-safe (`Send + Sync`); the decorator may emit from any
203/// thread that calls into the resource.
204pub trait LoggingSink: Send + Sync {
205    /// Accept a record. Implementations must not panic on full /
206    /// closed / IO-error conditions — return [`SinkError`] instead.
207    fn emit(&self, record: LogRecord) -> Result<(), SinkError>;
208}
209
210/// In-memory sink for tests and lightweight use. Records are
211/// appended to a `Vec` under a `Mutex`. `snapshot` returns a clone
212/// so consumers can inspect without holding the mutex.
213pub struct InMemorySink {
214    records: Mutex<Vec<LogRecord>>,
215}
216
217impl InMemorySink {
218    pub fn new() -> Self {
219        Self {
220            records: Mutex::new(Vec::new()),
221        }
222    }
223
224    /// Clone the current record buffer.
225    pub fn snapshot(&self) -> Vec<LogRecord> {
226        self.records.lock().expect("InMemorySink poisoned").clone()
227    }
228
229    /// Drop all buffered records. Useful for tests that want a
230    /// clean slate between phases.
231    pub fn clear(&self) {
232        self.records.lock().expect("InMemorySink poisoned").clear();
233    }
234
235    /// Number of records currently buffered.
236    pub fn len(&self) -> usize {
237        self.records.lock().expect("InMemorySink poisoned").len()
238    }
239
240    pub fn is_empty(&self) -> bool {
241        self.len() == 0
242    }
243}
244
245impl Default for InMemorySink {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251impl LoggingSink for InMemorySink {
252    fn emit(&self, record: LogRecord) -> Result<(), SinkError> {
253        self.records
254            .lock()
255            .expect("InMemorySink poisoned")
256            .push(record);
257        Ok(())
258    }
259}
260
261/// Discard sink: accepts every record and drops it. Use this when
262/// the test or production stack needs the runtime composition to
263/// match `LoggingResource(...)` shape but does **not** need to
264/// retain log records.
265///
266/// `InMemorySink` keeps every record alive for as long as the sink
267/// (and therefore the wrapping `LoggingResource`) lives. In long-
268/// running stress loops that compose
269/// `LoggingResource(InMemorySink)` once and reuse it across many
270/// iterations, that buffer grows unbounded — measurable memory and
271/// CPU overhead even if no test reads the records.
272/// `NullSink` solves that by discarding the record without
273/// allocating; the decorator still constructs the `LogRecord`
274/// (one stack-allocated value per call), but no per-record retention
275/// occurs.
276pub struct NullSink;
277
278impl NullSink {
279    pub fn new() -> Self {
280        Self
281    }
282}
283
284impl Default for NullSink {
285    fn default() -> Self {
286        Self
287    }
288}
289
290impl LoggingSink for NullSink {
291    fn emit(&self, _record: LogRecord) -> Result<(), SinkError> {
292        Ok(())
293    }
294}
295
296/// Telemetry decorator for [`DeviceMemoryResource`].
297pub struct LoggingResource {
298    inner: Box<dyn DeviceMemoryResource + Send + Sync>,
299    sink: std::sync::Arc<dyn LoggingSink>,
300    /// Count of records the sink refused or errored on. Surfaced as
301    /// a diagnostic for callers that want to detect telemetry loss
302    /// without halting the data-plane.
303    dropped_records: AtomicU64,
304}
305
306impl LoggingResource {
307    /// Wrap `inner` with `sink`. Records are emitted on every public
308    /// call; sink failures are counted in `dropped_records`.
309    pub fn new(
310        inner: Box<dyn DeviceMemoryResource + Send + Sync>,
311        sink: std::sync::Arc<dyn LoggingSink>,
312    ) -> Self {
313        Self {
314            inner,
315            sink,
316            dropped_records: AtomicU64::new(0),
317        }
318    }
319
320    /// Total records the sink has refused. A nonzero value means
321    /// telemetry was lost; allocator semantics are unaffected.
322    pub fn dropped_records(&self) -> u64 {
323        self.dropped_records.load(Ordering::Relaxed)
324    }
325
326    fn emit(&self, record: LogRecord) {
327        if self.sink.emit(record).is_err() {
328            self.dropped_records.fetch_add(1, Ordering::Relaxed);
329        }
330    }
331}
332
333impl DeviceMemoryResource for LoggingResource {
334    fn allocate(
335        &self,
336        bytes: usize,
337        stream: StreamId,
338        tag: AllocTag,
339    ) -> ResourceResult<DeviceBlock> {
340        let result = self.inner.allocate(bytes, stream, tag);
341        let (ptr, gen, recorded_bytes) = match &result {
342            Ok(b) => (Some(b.ptr), Some(b.generation), Some(b.bytes)),
343            Err(_) => (None, None, Some(bytes)),
344        };
345        self.emit(LogRecord {
346            action: LogAction::Allocate,
347            device_ordinal: self.inner.device_ordinal(),
348            stream_id: Some(stream),
349            ptr,
350            bytes: recorded_bytes,
351            tag: Some(tag),
352            generation: gen,
353            thread_id: current_thread_id_u64(),
354            order_counter: next_order_counter(),
355            timestamp_nanos: now_nanos(),
356            result: LogResult::from_result(&result),
357        });
358        result
359    }
360
361    fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
362        // Capture identifying fields before the move into inner —
363        // on success the block is consumed and we still need them
364        // for the log record.
365        let ptr = block.ptr;
366        let bytes = block.bytes;
367        let tag = block.tag;
368        let gen = block.generation;
369        let stream = block.alloc_stream;
370        let dev = block.device_ordinal;
371
372        let result = self.inner.deallocate(block);
373        self.emit(LogRecord {
374            action: LogAction::Deallocate,
375            device_ordinal: dev,
376            stream_id: Some(stream),
377            ptr: Some(ptr),
378            bytes: Some(bytes),
379            tag: Some(tag),
380            generation: Some(gen),
381            thread_id: current_thread_id_u64(),
382            order_counter: next_order_counter(),
383            timestamp_nanos: now_nanos(),
384            result: LogResult::from_result(&result),
385        });
386        result
387    }
388
389    fn device_ordinal(&self) -> u32 {
390        self.inner.device_ordinal()
391    }
392
393    fn bytes_outstanding(&self) -> usize {
394        self.inner.bytes_outstanding()
395    }
396
397    fn budget_snapshot(&self) -> Option<ResourceBudgetSnapshot> {
398        self.inner.budget_snapshot()
399    }
400
401    fn reap_pending(&self) -> ResourceResult<()> {
402        let result = self.inner.reap_pending();
403        self.emit(LogRecord {
404            action: LogAction::ReapPending,
405            device_ordinal: self.inner.device_ordinal(),
406            stream_id: None,
407            ptr: None,
408            bytes: None,
409            tag: None,
410            generation: None,
411            thread_id: current_thread_id_u64(),
412            order_counter: next_order_counter(),
413            timestamp_nanos: now_nanos(),
414            result: LogResult::from_result(&result),
415        });
416        result
417    }
418
419    fn record_block_use(&self, block: &DeviceBlock, use_stream: StreamId) -> ResourceResult<()> {
420        // Pass-through to the inner stream-ordered backend.
421        // No log record emitted: the launch builder calls this
422        // potentially many times per launch; recording each
423        // would balloon the log without telling the consumer
424        // anything they couldn't infer from Allocate/Deallocate
425        // pairs. If a future need arises, add a LogAction::Use
426        // variant rather than tagging this onto the existing
427        // shape.
428        self.inner.record_block_use(block, use_stream)
429    }
430
431    fn supports_block_use_tracking(&self) -> bool {
432        self.inner.supports_block_use_tracking()
433    }
434
435    fn prepare_block_use(
436        &self,
437        block: BlockId,
438        use_stream: StreamId,
439        access: Access,
440    ) -> ResourceResult<()> {
441        // Pass-through: same rationale as record_block_use.
442        // Pre-launch waits are an inner-backend concern.
443        self.inner.prepare_block_use(block, use_stream, access)
444    }
445
446    fn finish_block_use(
447        &self,
448        block: BlockId,
449        use_stream: StreamId,
450        access: Access,
451    ) -> ResourceResult<()> {
452        // Pass-through: see prepare_block_use rationale.
453        self.inner.finish_block_use(block, use_stream, access)
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::super::direct::DirectCudaResource;
460    use super::super::resource::BlockState;
461    use super::*;
462    use std::sync::Arc;
463
464    use crate::CudaDevice;
465
466    fn try_device() -> Option<Arc<CudaDevice>> {
467        CudaDevice::new(0).ok().map(Arc::new)
468    }
469
470    #[test]
471    fn pass_through_alloc_dealloc_emits_two_ok_records() {
472        let Some(device) = try_device() else {
473            return;
474        };
475        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
476        let sink = Arc::new(InMemorySink::new());
477        let r = LoggingResource::new(inner, sink.clone());
478
479        let block = r
480            .allocate(1024, StreamId::DEFAULT, AllocTag("logging-test"))
481            .expect("alloc");
482        assert_eq!(block.bytes, 1024);
483        assert_eq!(block.state, BlockState::Live);
484        r.deallocate(block).expect("dealloc");
485
486        let recs = sink.snapshot();
487        assert_eq!(recs.len(), 2, "expected 2 records, got {:?}", recs);
488        assert_eq!(recs[0].action, LogAction::Allocate);
489        assert_eq!(recs[0].result, LogResult::Ok);
490        assert_eq!(recs[0].bytes, Some(1024));
491        assert_eq!(recs[0].stream_id, Some(StreamId::DEFAULT));
492        assert!(recs[0].ptr.is_some());
493
494        assert_eq!(recs[1].action, LogAction::Deallocate);
495        assert_eq!(recs[1].result, LogResult::Ok);
496        assert_eq!(recs[1].ptr, recs[0].ptr);
497        assert_eq!(recs[1].generation, recs[0].generation);
498    }
499
500    #[test]
501    fn order_counter_strictly_increases_across_records() {
502        let Some(device) = try_device() else {
503            return;
504        };
505        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
506        let sink = Arc::new(InMemorySink::new());
507        let r = LoggingResource::new(inner, sink.clone());
508
509        for _ in 0..4 {
510            let b = r
511                .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
512                .expect("alloc");
513            r.deallocate(b).expect("dealloc");
514        }
515        r.reap_pending().expect("reap");
516
517        let recs = sink.snapshot();
518        assert_eq!(recs.len(), 9); // 4*alloc + 4*dealloc + 1 reap
519        let mut last = 0u64;
520        for rec in &recs {
521            assert!(
522                rec.order_counter > last,
523                "order_counter must strictly increase: prev={}, now={}",
524                last,
525                rec.order_counter
526            );
527            last = rec.order_counter;
528        }
529    }
530
531    #[test]
532    fn failed_alloc_records_error_result() {
533        let Some(device) = try_device() else {
534            return;
535        };
536        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
537        let sink = Arc::new(InMemorySink::new());
538        let r = LoggingResource::new(inner, sink.clone());
539
540        // Zero-byte alloc fails per resource contract.
541        let _ = r.allocate(0, StreamId::DEFAULT, AllocTag::UNTAGGED);
542        let recs = sink.snapshot();
543        assert_eq!(recs.len(), 1);
544        assert_eq!(recs[0].action, LogAction::Allocate);
545        assert!(matches!(recs[0].result, LogResult::Err { kind, .. } if kind == "Driver"));
546        // Failed allocs still record the requested byte count.
547        assert_eq!(recs[0].bytes, Some(0));
548        assert!(recs[0].ptr.is_none());
549        assert!(recs[0].generation.is_none());
550    }
551
552    #[test]
553    fn failed_dealloc_records_error_result() {
554        let Some(device) = try_device() else {
555            return;
556        };
557        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
558        let sink = Arc::new(InMemorySink::new());
559        let r = LoggingResource::new(inner, sink.clone());
560
561        let bogus = DeviceBlock {
562            ptr: 0xdead_beef,
563            device_ordinal: 0,
564            alloc_stream: StreamId::DEFAULT,
565            bytes: 16,
566            align: 1,
567            tag: AllocTag::UNTAGGED,
568            generation: Generation::next(),
569            state: BlockState::Live,
570        };
571        let res = r.deallocate(bogus);
572        assert!(res.is_err());
573        let recs = sink.snapshot();
574        assert_eq!(recs.len(), 1);
575        assert_eq!(recs[0].action, LogAction::Deallocate);
576        assert!(matches!(recs[0].result, LogResult::Err { kind, .. } if kind == "UseAfterFree"));
577        assert_eq!(recs[0].ptr, Some(0xdead_beef));
578    }
579
580    #[test]
581    fn reap_pending_emits_record() {
582        let Some(device) = try_device() else {
583            return;
584        };
585        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
586        let sink = Arc::new(InMemorySink::new());
587        let r = LoggingResource::new(inner, sink.clone());
588
589        r.reap_pending().expect("reap");
590        let recs = sink.snapshot();
591        assert_eq!(recs.len(), 1);
592        assert_eq!(recs[0].action, LogAction::ReapPending);
593        assert_eq!(recs[0].result, LogResult::Ok);
594        assert!(recs[0].stream_id.is_none());
595        assert!(recs[0].ptr.is_none());
596    }
597
598    #[test]
599    fn sink_failure_increments_dropped_records_but_does_not_break_alloc() {
600        // Custom sink that always refuses. Verifies LoggingResource
601        // never propagates sink errors and counts losses.
602        struct RefuseAllSink;
603        impl LoggingSink for RefuseAllSink {
604            fn emit(&self, _r: LogRecord) -> Result<(), SinkError> {
605                Err(SinkError::Refused("test sink refuses all".into()))
606            }
607        }
608
609        let Some(device) = try_device() else {
610            return;
611        };
612        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
613        let sink = Arc::new(RefuseAllSink);
614        let r = LoggingResource::new(inner, sink);
615
616        // Allocator semantics are still correct.
617        let block = r
618            .allocate(128, StreamId::DEFAULT, AllocTag("refuse-test"))
619            .expect("alloc must succeed even when sink refuses");
620        r.deallocate(block).expect("dealloc must succeed too");
621        // Two refused records (alloc + dealloc).
622        assert_eq!(r.dropped_records(), 2);
623    }
624
625    #[test]
626    fn forwards_bytes_outstanding_and_device_ordinal() {
627        let Some(device) = try_device() else {
628            return;
629        };
630        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 3));
631        let sink = Arc::new(InMemorySink::new());
632        let r = LoggingResource::new(inner, sink);
633
634        assert_eq!(r.device_ordinal(), 3);
635        assert_eq!(r.bytes_outstanding(), 0);
636    }
637
638    #[test]
639    fn truncate_handles_non_ascii_at_cap_without_panicking() {
640        // `String::truncate(cap)` would panic if `cap` lands inside
641        // a multibyte UTF-8 sequence; the boundary-safe `truncate`
642        // helper must walk back to the previous boundary and
643        // succeed instead. Regression for the logging-path
644        // panic the decorator's "never panics on telemetry"
645        // contract requires.
646        // "héllo": h, é (2 bytes), l, l, o = 6 bytes total. Cap=2
647        // would slice into é if naive.
648        let s = String::from("héllo");
649        let out = truncate(s, 2);
650        assert!(out.starts_with('h'));
651        assert!(out.ends_with('…'));
652
653        // Cap larger than string is a no-op.
654        let out = truncate(String::from("ok"), 100);
655        assert_eq!(out, "ok");
656
657        // Cap = 0 produces just the ellipsis.
658        let out = truncate(String::from("héllo"), 0);
659        assert_eq!(out, "…");
660
661        // Cap on a valid char boundary does not regress the simple
662        // path.
663        let out = truncate(String::from("abcdefgh"), 3);
664        assert_eq!(out, "abc…");
665    }
666
667    #[test]
668    fn null_sink_accepts_records_without_retention() {
669        let sink = NullSink::new();
670        let rec = LogRecord {
671            action: LogAction::Allocate,
672            device_ordinal: 0,
673            stream_id: Some(StreamId::DEFAULT),
674            ptr: Some(0xdead_beef),
675            bytes: Some(64),
676            tag: Some(AllocTag::UNTAGGED),
677            generation: Some(Generation::next()),
678            thread_id: 0,
679            order_counter: 1,
680            timestamp_nanos: 0,
681            result: LogResult::Ok,
682        };
683        sink.emit(rec).expect("NullSink never errors");
684        // No retention surface to inspect — the contract is that
685        // emit succeeded and nothing else happened.
686    }
687}