- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
612 lines
21 KiB
Rust
612 lines
21 KiB
Rust
//! Write-Ahead Log for signal event durability.
|
|
//!
|
|
//! The WAL is the durability primitive for signal events. Every view, like,
|
|
//! skip, and completion is appended to the WAL before any aggregation occurs.
|
|
//! Signal aggregates, decay scores, and windowed counts are derived state
|
|
//! that can always be rebuilt from WAL replay.
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! - **Batch-oriented**: events are grouped into batches (up to 256 events)
|
|
//! and written as a single atomic unit with one BLAKE3 checksum and one fsync.
|
|
//! - **Group commit**: a dedicated writer thread accumulates events from
|
|
//! concurrent callers, forming batches by count or timeout.
|
|
//! - **Segment files**: the WAL is split into 16 MB segment files for
|
|
//! efficient truncation after checkpointing.
|
|
//! - **Deduplication**: a double-buffered `HashSet<u128>` detects duplicate
|
|
//! events within a configurable time window.
|
|
//! - **Crash recovery**: two-phase validation (magic + bounds, then BLAKE3)
|
|
//! with automatic truncation of corrupted tails.
|
|
|
|
pub mod checkpoint;
|
|
pub mod compaction;
|
|
pub mod config;
|
|
pub mod dedup;
|
|
pub mod diagnostics;
|
|
pub mod error;
|
|
pub mod format;
|
|
pub mod reader;
|
|
pub mod segment;
|
|
pub mod session_journal;
|
|
pub mod writer;
|
|
|
|
use std::{fs, path::PathBuf};
|
|
|
|
pub use config::WalConfig;
|
|
use crossbeam::channel::{Sender, bounded};
|
|
|
|
use self::{
|
|
dedup::DedupWindow,
|
|
error::WalError,
|
|
format::{EventRecord, SessionWalEvent},
|
|
segment::SegmentWriter,
|
|
session_journal::SessionJournal,
|
|
writer::{WalCommand, WriterConfig},
|
|
};
|
|
use crate::replication::{RegionId, ShardId};
|
|
|
|
/// Default channel capacity for the writer command channel.
|
|
const DEFAULT_CHANNEL_CAPACITY: usize = 10_000;
|
|
|
|
/// A signal event to be appended to the WAL.
|
|
///
|
|
/// This is the public write type. It maps 1:1 to the internal
|
|
/// `EventRecord` wire format.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct SignalEvent {
|
|
pub entity_id: u64,
|
|
pub signal_type: u8,
|
|
pub weight: f32,
|
|
pub timestamp_nanos: u64,
|
|
}
|
|
|
|
impl From<SignalEvent> for EventRecord {
|
|
fn from(e: SignalEvent) -> Self {
|
|
// A bare `SignalEvent` is a default-scope (local) write; the WAL v3
|
|
// governance envelope defaults to zero. Scoped writes construct the
|
|
// `EventRecord` directly via `EventRecord::scoped` and bypass this path.
|
|
Self::signal(e.entity_id, e.signal_type, e.weight, e.timestamp_nanos)
|
|
}
|
|
}
|
|
|
|
impl From<EventRecord> for SignalEvent {
|
|
fn from(e: EventRecord) -> Self {
|
|
Self {
|
|
entity_id: e.entity_id,
|
|
signal_type: e.signal_type,
|
|
weight: e.weight,
|
|
timestamp_nanos: e.timestamp_nanos,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A cloneable, `Send + Sync` sender for WAL append operations.
|
|
///
|
|
/// Created by [`WalHandle::sender()`]. Allows WAL append to be called
|
|
/// from components (e.g. `WalHandleWriter`) that must be `Send + Sync`
|
|
/// without sharing the full `WalHandle` (which owns the writer thread).
|
|
#[derive(Clone)]
|
|
pub struct WalSender {
|
|
tx: Sender<WalCommand>,
|
|
}
|
|
|
|
impl WalSender {
|
|
/// Append a signal event. Blocks until the batch containing this event
|
|
/// has been durably fsynced to disk.
|
|
///
|
|
/// Returns the assigned monotonic sequence number.
|
|
/// Returns `Ok(0)` if the event was deduplicated.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::SendFailed` if the writer thread has panicked.
|
|
pub fn append(&self, event: SignalEvent) -> Result<u64, WalError> {
|
|
let (reply_tx, reply_rx) = bounded(1);
|
|
self.tx
|
|
.send(WalCommand::Append {
|
|
event: event.into(),
|
|
reply: reply_tx,
|
|
})
|
|
.map_err(|_| WalError::SendFailed)?;
|
|
reply_rx.recv().map_err(|_| WalError::SendFailed)?
|
|
}
|
|
|
|
/// Append a pre-built [`EventRecord`], preserving its v3 governance envelope
|
|
/// (scope, writer agent, share-policy version, membership epoch).
|
|
///
|
|
/// This is the scoped-write counterpart to [`append`](Self::append), which
|
|
/// only carries the default (local) envelope. Blocks until the batch
|
|
/// containing this event has been durably fsynced. Returns the assigned
|
|
/// monotonic sequence number, or `Ok(0)` if the event was deduplicated.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::SendFailed` if the writer thread has panicked.
|
|
pub fn append_record(&self, record: EventRecord) -> Result<u64, WalError> {
|
|
let (reply_tx, reply_rx) = bounded(1);
|
|
self.tx
|
|
.send(WalCommand::Append {
|
|
event: record,
|
|
reply: reply_tx,
|
|
})
|
|
.map_err(|_| WalError::SendFailed)?;
|
|
reply_rx.recv().map_err(|_| WalError::SendFailed)?
|
|
}
|
|
}
|
|
|
|
/// Handle to the WAL. Provides the public API for appending events,
|
|
/// checkpointing, and truncation.
|
|
///
|
|
/// Internally manages a writer thread that performs group commit.
|
|
/// All public methods are safe to call from multiple threads concurrently.
|
|
pub struct WalHandle {
|
|
tx: Sender<WalCommand>,
|
|
thread: Option<std::thread::JoinHandle<Result<(), WalError>>>,
|
|
wal_dir: PathBuf,
|
|
}
|
|
|
|
impl std::fmt::Debug for WalHandle {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("WalHandle")
|
|
.field("wal_dir", &self.wal_dir)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl WalHandle {
|
|
/// Clone the channel sender for use in separate components.
|
|
///
|
|
/// The returned [`WalSender`] is `Clone + Send + Sync` and can be used
|
|
/// from multiple threads concurrently to append events without holding
|
|
/// a reference to the full `WalHandle`.
|
|
#[must_use]
|
|
pub fn sender(&self) -> WalSender {
|
|
WalSender {
|
|
tx: self.tx.clone(),
|
|
}
|
|
}
|
|
|
|
/// Return the number of pending commands in the writer channel.
|
|
///
|
|
/// O(1) operation. Used by the backpressure check in `TidalDb::signal()`
|
|
/// to detect queue saturation before enqueuing.
|
|
#[must_use]
|
|
pub fn channel_len(&self) -> usize {
|
|
self.tx.len()
|
|
}
|
|
|
|
/// Open the WAL directory, recover from any crash, and return a ready handle.
|
|
///
|
|
/// Returns the handle, a list of replayed signal events since the last
|
|
/// checkpoint, and a list of recovered session journal events (for the
|
|
/// session materializer to process).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError` on I/O failure or unrecoverable corruption.
|
|
// Config is consumed by value: fields are moved into WriterConfig for the spawned thread.
|
|
#[allow(clippy::needless_pass_by_value)]
|
|
pub fn open(
|
|
config: WalConfig,
|
|
) -> Result<(Self, Vec<SignalEvent>, Vec<SessionWalEvent>), WalError> {
|
|
// Validate the batch size against the wire-format limit *before* spawning
|
|
// the writer thread. A `batch_size` above `MAX_EVENTS_PER_BATCH` would
|
|
// make the first full batch fail to encode, terminating the writer thread
|
|
// and permanently wedging every subsequent append. Reject it here so the
|
|
// caller fails loudly at startup with a typed `InvalidConfig` error
|
|
// instead of silently after the first full batch.
|
|
writer::validate_writer_config(config.batch_size)?;
|
|
|
|
let wal_dir = config.wal_dir();
|
|
fs::create_dir_all(&wal_dir)?;
|
|
|
|
// Recover from any previous crash
|
|
let recovery = reader::recover(&wal_dir)?;
|
|
let replayed_events: Vec<SignalEvent> = recovery
|
|
.events
|
|
.iter()
|
|
.cloned()
|
|
.map(SignalEvent::from)
|
|
.collect();
|
|
// Sequence 0 is reserved as the dedup sentinel (returned for duplicate events).
|
|
// Real events always get seq >= 1.
|
|
let next_seq = recovery.next_seq.max(1);
|
|
|
|
// Recover session journal events.
|
|
let session_journal_path = wal_dir.join(session_journal::SESSION_JOURNAL_FILENAME);
|
|
let session_events = SessionJournal::recover(&session_journal_path)?;
|
|
|
|
// Initialize dedup window from replayed events
|
|
let mut dedup = DedupWindow::new(config.dedup_window);
|
|
dedup.populate_from_events(recovery.events);
|
|
|
|
// Open (or create) the current segment
|
|
// Find the segment that should receive new writes
|
|
let segments = segment::list_segments(&wal_dir)?;
|
|
let segment_first_seq = if let Some((last_seg_seq, _)) = segments.last() {
|
|
*last_seg_seq
|
|
} else {
|
|
// No segments exist yet.
|
|
next_seq
|
|
};
|
|
|
|
let segment = SegmentWriter::open(
|
|
&wal_dir,
|
|
ShardId::SINGLE,
|
|
segment_first_seq,
|
|
config.segment_size,
|
|
)?;
|
|
|
|
// Create the command channel
|
|
let (tx, rx) = bounded(DEFAULT_CHANNEL_CAPACITY);
|
|
|
|
let writer_config = WriterConfig {
|
|
dir: wal_dir.clone(),
|
|
segment_size: config.segment_size,
|
|
batch_size: config.batch_size,
|
|
batch_timeout: config.batch_timeout,
|
|
dedup_window: config.dedup_window,
|
|
session_journal_path: Some(session_journal_path),
|
|
// Single-node default. Multi-shard deployments override via NodeConfig
|
|
// before open() is called.
|
|
shard_id: ShardId::SINGLE,
|
|
region_id: RegionId::SINGLE,
|
|
};
|
|
|
|
// Spawn the writer thread
|
|
let thread = std::thread::Builder::new()
|
|
.name("tidaldb-wal-writer".into())
|
|
.spawn(move || writer::run_writer(&rx, &writer_config, segment, next_seq, dedup))
|
|
.map_err(|e| WalError::Io(std::io::Error::other(e)))?;
|
|
|
|
let segment_count = segments.len();
|
|
let replayed_count = replayed_events.len();
|
|
let session_event_count = session_events.len();
|
|
|
|
tracing::info!(
|
|
segments = segment_count,
|
|
replayed_events = replayed_count,
|
|
session_events = session_event_count,
|
|
next_seq,
|
|
"wal: recovery complete"
|
|
);
|
|
|
|
Ok((
|
|
Self {
|
|
tx,
|
|
thread: Some(thread),
|
|
wal_dir,
|
|
},
|
|
replayed_events,
|
|
session_events,
|
|
))
|
|
}
|
|
|
|
/// Append a signal event. Blocks until the batch containing this event
|
|
/// has been durably fsynced to disk.
|
|
///
|
|
/// Returns the assigned monotonic sequence number.
|
|
/// Returns `Ok(0)` if the event was deduplicated (already seen).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::Closed` if the WAL has been shut down.
|
|
/// Returns `WalError::SendFailed` if the writer thread has panicked.
|
|
pub fn append(&self, event: SignalEvent) -> Result<u64, WalError> {
|
|
let (reply_tx, reply_rx) = bounded(1);
|
|
self.tx
|
|
.send(WalCommand::Append {
|
|
event: event.into(),
|
|
reply: reply_tx,
|
|
})
|
|
.map_err(|_| WalError::SendFailed)?;
|
|
|
|
reply_rx.recv().map_err(|_| WalError::SendFailed)?
|
|
}
|
|
|
|
// ── Session journal methods ────────────────────────────────────────────
|
|
//
|
|
// These send fire-and-forget session commands to the writer thread.
|
|
// The writer thread writes them to the session journal (separate file)
|
|
// with per-write fsync. Errors are swallowed: session WAL writes are
|
|
// best-effort; in-memory state is the source of truth.
|
|
|
|
/// Record a session start in the session journal.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::SendFailed` if the writer thread has exited.
|
|
pub fn session_start(
|
|
&self,
|
|
session_id: u64,
|
|
user_id: u64,
|
|
started_at_ns: u64,
|
|
agent_id: &str,
|
|
policy_name: &str,
|
|
) -> Result<(), WalError> {
|
|
self.tx
|
|
.send(WalCommand::SessionStart {
|
|
session_id,
|
|
user_id,
|
|
started_at_ns,
|
|
agent_id: agent_id.to_owned(),
|
|
policy_name: policy_name.to_owned(),
|
|
})
|
|
.map_err(|_| WalError::SendFailed)
|
|
}
|
|
|
|
/// Record a session signal in the session journal.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::SendFailed` if the writer thread has exited.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn session_signal(
|
|
&self,
|
|
session_id: u64,
|
|
entity_id: u64,
|
|
weight: f32,
|
|
ts_ns: u64,
|
|
signal_name: &str,
|
|
annotation: Option<&str>,
|
|
session_seqno: Option<u64>,
|
|
idempotency_key: Option<u128>,
|
|
) -> Result<(), WalError> {
|
|
self.tx
|
|
.send(WalCommand::SessionSignal {
|
|
session_id,
|
|
entity_id,
|
|
weight,
|
|
ts_ns,
|
|
signal_name: signal_name.to_owned(),
|
|
annotation: annotation.map(str::to_owned),
|
|
session_seqno,
|
|
idempotency_key,
|
|
})
|
|
.map_err(|_| WalError::SendFailed)
|
|
}
|
|
|
|
/// Record a session close in the session journal.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::SendFailed` if the writer thread has exited.
|
|
pub fn session_close(&self, session_id: u64) -> Result<(), WalError> {
|
|
self.tx
|
|
.send(WalCommand::SessionClose { session_id })
|
|
.map_err(|_| WalError::SendFailed)
|
|
}
|
|
|
|
/// Write a checkpoint marker at the given sequence number.
|
|
///
|
|
/// Called by the signal materializer (P1.4) after flushing in-memory
|
|
/// signal state to durable storage.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::Io` on filesystem failure.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if the system clock is before the Unix epoch.
|
|
pub fn checkpoint(&self, seq: u64) -> Result<(), WalError> {
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("system clock is before Unix epoch")
|
|
.as_nanos();
|
|
let ts_u64 = ts as u64;
|
|
checkpoint::CheckpointManager::write(&self.wal_dir, seq, ts_u64)
|
|
}
|
|
|
|
/// Delete WAL segments whose events are all before `seq`.
|
|
///
|
|
/// The truncation runs inside the writer thread to avoid racing with
|
|
/// concurrent writes to segment files. Blocks until the writer thread
|
|
/// has completed the deletion.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::Io` on filesystem failure.
|
|
/// Returns `WalError::Closed` if the writer thread has exited.
|
|
pub fn truncate_before(&self, seq: u64) -> Result<(), WalError> {
|
|
let (reply_tx, reply_rx) = crossbeam::channel::bounded(1);
|
|
self.tx
|
|
.send(WalCommand::TruncateBefore {
|
|
before_seq: seq,
|
|
reply: reply_tx,
|
|
})
|
|
.map_err(|_| WalError::Closed)?;
|
|
reply_rx.recv().map_err(|_| WalError::Closed)?
|
|
}
|
|
|
|
/// Graceful shutdown: signal the writer thread to flush remaining events,
|
|
/// fsync, and exit. Blocks until the writer thread terminates.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `WalError::ShutdownFailed` if the writer thread panicked.
|
|
pub fn shutdown(mut self) -> Result<(), WalError> {
|
|
// Send shutdown command (ignore send error -- writer may already be gone)
|
|
let _ = self.tx.send(WalCommand::Shutdown);
|
|
|
|
if let Some(thread) = self.thread.take() {
|
|
match thread.join() {
|
|
Ok(result) => result?,
|
|
Err(_) => return Err(WalError::ShutdownFailed),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for WalHandle {
|
|
fn drop(&mut self) {
|
|
// Best-effort shutdown if not already shut down
|
|
if self.thread.is_some() {
|
|
let _ = self.tx.send(WalCommand::Shutdown);
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_config(dir: &std::path::Path) -> WalConfig {
|
|
WalConfig {
|
|
dir: dir.to_path_buf(),
|
|
..WalConfig::default()
|
|
}
|
|
}
|
|
|
|
fn make_event(id: u64) -> SignalEvent {
|
|
SignalEvent {
|
|
entity_id: id,
|
|
signal_type: 1,
|
|
weight: 1.0,
|
|
timestamp_nanos: id * 1_000_000_000,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_creates_wal_directory() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let config = test_config(dir.path());
|
|
let wal_dir = config.wal_dir();
|
|
|
|
let (handle, replayed, _session_events) =
|
|
WalHandle::open(config).expect("open should succeed");
|
|
assert!(wal_dir.exists());
|
|
assert!(replayed.is_empty());
|
|
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn append_returns_sequence_number() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let config = test_config(dir.path());
|
|
|
|
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
|
|
let seq = handle.append(make_event(1)).expect("append should succeed");
|
|
// Sequence is always non-negative (u64), just verify we got a value
|
|
let _ = seq;
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn append_multiple_monotonic() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let config = test_config(dir.path());
|
|
|
|
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
|
|
|
|
let mut seqs = Vec::new();
|
|
for i in 1..=10 {
|
|
let seq = handle.append(make_event(i)).expect("append should succeed");
|
|
seqs.push(seq);
|
|
}
|
|
|
|
// Filter out dedup seq=0 (should be none for unique events)
|
|
let non_zero: Vec<u64> = seqs.iter().copied().filter(|&s| s > 0).collect();
|
|
for window in non_zero.windows(2) {
|
|
assert!(window[0] < window[1], "not monotonic: {non_zero:?}");
|
|
}
|
|
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn dedup_returns_zero() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let config = test_config(dir.path());
|
|
|
|
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
|
|
|
|
let event = make_event(42);
|
|
let seq1 = handle
|
|
.append(event.clone())
|
|
.expect("first append should succeed");
|
|
let seq2 = handle.append(event).expect("second append should succeed");
|
|
|
|
assert!(seq1 > 0);
|
|
assert_eq!(seq2, 0); // deduplicated
|
|
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn checkpoint_writes_file() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let config = test_config(dir.path());
|
|
let wal_dir = config.wal_dir();
|
|
|
|
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
|
|
handle.append(make_event(1)).expect("append should succeed");
|
|
handle.checkpoint(1).expect("checkpoint should succeed");
|
|
|
|
let cp = checkpoint::CheckpointManager::read(&wal_dir).expect("read should succeed");
|
|
assert!(cp.is_some());
|
|
let (seq, _ts) = cp.expect("checkpoint should exist");
|
|
assert_eq!(seq, 1);
|
|
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn close_and_reopen_continues_sequence() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
|
|
// First session
|
|
let config = test_config(dir.path());
|
|
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
|
|
let mut last_seq = 0;
|
|
for i in 1..=5 {
|
|
let seq = handle.append(make_event(i)).expect("append should succeed");
|
|
if seq > last_seq {
|
|
last_seq = seq;
|
|
}
|
|
}
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
|
|
// Second session
|
|
let config = test_config(dir.path());
|
|
let (handle, replayed, _session_events) =
|
|
WalHandle::open(config).expect("reopen should succeed");
|
|
assert_eq!(replayed.len(), 5);
|
|
|
|
// New events should get higher sequence numbers
|
|
let new_seq = handle
|
|
.append(make_event(100))
|
|
.expect("append should succeed");
|
|
assert!(
|
|
new_seq > last_seq,
|
|
"new_seq {new_seq} should be > last_seq {last_seq}"
|
|
);
|
|
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn signal_event_converts_to_event_record() {
|
|
let signal = make_event(42);
|
|
let record: EventRecord = signal.clone().into();
|
|
assert_eq!(record.entity_id, 42);
|
|
assert_eq!(record.signal_type, 1);
|
|
assert_eq!(record.weight.to_bits(), signal.weight.to_bits());
|
|
}
|
|
|
|
#[test]
|
|
fn event_record_converts_to_signal_event() {
|
|
let record = EventRecord::signal(42, 1, 2.5, 1_000_000_000);
|
|
let signal: SignalEvent = record.into();
|
|
assert_eq!(signal.entity_id, 42);
|
|
assert_eq!(signal.signal_type, 1);
|
|
assert_eq!(signal.weight.to_bits(), 2.5_f32.to_bits());
|
|
}
|
|
}
|