huskies: merge 1124 story Persist TransitionFired into a per-sled CRDT event log
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
//! Read/write helpers for the `event_log` append-only list in the CRDT document.
|
||||
//!
|
||||
//! Every pipeline stage transition is appended as an [`EventLogEntryCrdt`][super::super::types::EventLogEntryCrdt]
|
||||
//! entry. Entries are never updated or tombstoned — the list is strictly grow-only.
|
||||
//! Monotonic sequencing is computed at write time while holding the CRDT lock,
|
||||
//! so `event_seq` values for a given sled are always contiguous and gap-free.
|
||||
|
||||
use bft_json_crdt::json_crdt::{JsonValue, *};
|
||||
use bft_json_crdt::op::ROOT_ID;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::state::{apply_and_persist, get_crdt};
|
||||
use super::super::types::EventLogEntryCrdt;
|
||||
|
||||
/// Raw event log entry extracted from the CRDT document.
|
||||
///
|
||||
/// All fields are decoded to Rust primitives; entries with a missing or
|
||||
/// malformed `sled_id` are silently dropped by [`read_all_event_log_entries`].
|
||||
pub struct EventLogEntryRaw {
|
||||
/// Monotonic sequence number for the recording sled (0-based).
|
||||
pub event_seq: u64,
|
||||
/// Hex-encoded Ed25519 public key of the sled that wrote this entry.
|
||||
pub sled_id: String,
|
||||
/// Unix timestamp (seconds) when the transition fired.
|
||||
pub timestamp: f64,
|
||||
/// Story ID of the work item that transitioned.
|
||||
pub story_id: String,
|
||||
/// Human-readable label of the stage before the transition.
|
||||
pub from_stage: String,
|
||||
/// Human-readable label of the stage after the transition.
|
||||
pub to_stage: String,
|
||||
/// String label of the `PipelineEvent` variant.
|
||||
pub pipeline_event: String,
|
||||
}
|
||||
|
||||
/// Append a new event log entry to the CRDT, computing the monotonic `event_seq`
|
||||
/// atomically while the CRDT lock is held.
|
||||
///
|
||||
/// No-ops silently when the CRDT is not yet initialised.
|
||||
pub fn append_event_log_entry(
|
||||
sled_id: &str,
|
||||
timestamp: f64,
|
||||
story_id: &str,
|
||||
from_stage: &str,
|
||||
to_stage: &str,
|
||||
pipeline_event: &str,
|
||||
) {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut state) = state_mutex.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Count existing entries for this sled while holding the lock so the seq
|
||||
// is computed and used in the same critical section — no TOCTOU gap.
|
||||
let event_seq = state
|
||||
.crdt
|
||||
.doc
|
||||
.event_log
|
||||
.iter()
|
||||
.filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if s == sled_id))
|
||||
.count() as f64;
|
||||
|
||||
// Append after the last existing entry so the list stays in insertion order.
|
||||
// Inserting after ROOT_ID would place each entry at the front (RGA semantics),
|
||||
// reversing the sequence; inserting after the current tail preserves order.
|
||||
let total_len = state.crdt.doc.event_log.view().len();
|
||||
let after = if total_len > 0 {
|
||||
super::list_id_at(&state.crdt.doc.event_log, total_len - 1).unwrap_or(ROOT_ID)
|
||||
} else {
|
||||
ROOT_ID
|
||||
};
|
||||
|
||||
let entry: JsonValue = json!({
|
||||
"event_seq": event_seq,
|
||||
"sled_id": sled_id,
|
||||
"timestamp": timestamp,
|
||||
"story_id": story_id,
|
||||
"from_stage": from_stage,
|
||||
"to_stage": to_stage,
|
||||
"pipeline_event": pipeline_event,
|
||||
})
|
||||
.into();
|
||||
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.event_log.insert(after, entry));
|
||||
}
|
||||
|
||||
/// Read all event log entries from the CRDT document.
|
||||
///
|
||||
/// Entries with a missing or empty `sled_id` are silently skipped.
|
||||
/// Order reflects CRDT insertion order (RGA list semantics).
|
||||
pub fn read_all_event_log_entries() -> Vec<EventLogEntryRaw> {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(state) = state_mutex.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
state
|
||||
.crdt
|
||||
.doc
|
||||
.event_log
|
||||
.iter()
|
||||
.filter_map(extract_entry)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a CRDT event log entry to its read-side representation.
|
||||
fn extract_entry(e: &EventLogEntryCrdt) -> Option<EventLogEntryRaw> {
|
||||
let event_seq = match e.event_seq.view() {
|
||||
JsonValue::Number(n) => n as u64,
|
||||
_ => return None,
|
||||
};
|
||||
let sled_id = match e.sled_id.view() {
|
||||
JsonValue::String(s) if !s.is_empty() => s,
|
||||
_ => return None,
|
||||
};
|
||||
let timestamp = match e.timestamp.view() {
|
||||
JsonValue::Number(n) => n,
|
||||
_ => 0.0,
|
||||
};
|
||||
let story_id = match e.story_id.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let from_stage = match e.from_stage.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let to_stage = match e.to_stage.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let pipeline_event = match e.pipeline_event.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(EventLogEntryRaw {
|
||||
event_seq,
|
||||
sled_id,
|
||||
timestamp,
|
||||
story_id,
|
||||
from_stage,
|
||||
to_stage,
|
||||
pipeline_event,
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use bft_json_crdt::op::OpId;
|
||||
|
||||
mod active_agents;
|
||||
mod agent_throttle;
|
||||
mod event_log;
|
||||
mod gateway_projects;
|
||||
mod merge_jobs;
|
||||
mod test_jobs;
|
||||
@@ -28,6 +29,7 @@ pub use active_agents::{
|
||||
pub use agent_throttle::{
|
||||
delete_agent_throttle, read_agent_throttle, read_all_agent_throttles, write_agent_throttle,
|
||||
};
|
||||
pub use event_log::{EventLogEntryRaw, append_event_log_entry, read_all_event_log_entries};
|
||||
pub use gateway_projects::{
|
||||
delete_gateway_project, read_all_gateway_projects, read_gateway_project, write_gateway_project,
|
||||
};
|
||||
|
||||
@@ -28,12 +28,13 @@ mod write;
|
||||
|
||||
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
|
||||
pub use lww_maps::{
|
||||
delete_active_agent, delete_agent_throttle, delete_gateway_project, delete_merge_job,
|
||||
delete_test_job, delete_token_usage, read_active_agent, read_agent_throttle,
|
||||
read_all_active_agents, read_all_agent_throttles, read_all_gateway_projects,
|
||||
read_all_merge_jobs, read_all_test_jobs, read_all_token_usage, read_gateway_project,
|
||||
read_merge_job, read_test_job, read_token_usage, write_active_agent, write_agent_throttle,
|
||||
write_gateway_project, write_merge_job, write_test_job, write_token_usage,
|
||||
EventLogEntryRaw, append_event_log_entry, delete_active_agent, delete_agent_throttle,
|
||||
delete_gateway_project, delete_merge_job, delete_test_job, delete_token_usage,
|
||||
read_active_agent, read_agent_throttle, read_all_active_agents, read_all_agent_throttles,
|
||||
read_all_event_log_entries, read_all_gateway_projects, read_all_merge_jobs, read_all_test_jobs,
|
||||
read_all_token_usage, read_gateway_project, read_merge_job, read_test_job, read_token_usage,
|
||||
write_active_agent, write_agent_throttle, write_gateway_project, write_merge_job,
|
||||
write_test_job, write_token_usage,
|
||||
};
|
||||
pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops};
|
||||
pub use presence::{
|
||||
@@ -49,9 +50,9 @@ pub(crate) use state::flush_persistence;
|
||||
pub use state::{init, subscribe};
|
||||
pub use types::{
|
||||
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
|
||||
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
|
||||
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView,
|
||||
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem,
|
||||
EventLogEntryCrdt, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt,
|
||||
MergeJobView, NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt,
|
||||
PipelineItemView, TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem,
|
||||
};
|
||||
pub use write::{
|
||||
bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs,
|
||||
|
||||
@@ -46,6 +46,34 @@ pub struct PipelineDoc {
|
||||
pub agent_throttle: ListCrdt<AgentThrottleCrdt>,
|
||||
pub gateway_projects: ListCrdt<GatewayProjectCrdt>,
|
||||
pub gateway_config: GatewayConfigCrdt,
|
||||
/// Append-only log of every pipeline transition, persisted as CRDT ops.
|
||||
pub event_log: ListCrdt<EventLogEntryCrdt>,
|
||||
}
|
||||
|
||||
/// CRDT entry representing a single persisted pipeline stage-transition event.
|
||||
///
|
||||
/// Entries are append-only; once written they are never updated or tombstoned.
|
||||
/// The `event_seq` field is a per-sled monotonic counter computed at write time
|
||||
/// (count of existing entries for that sled), giving deterministic ordering for
|
||||
/// all transitions recorded by a single node even after CRDT replay on restart.
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
pub struct EventLogEntryCrdt {
|
||||
/// Monotonic sequence number for this sled (0, 1, 2, …). Stored as `f64`
|
||||
/// because all CRDT scalar registers use JSON numbers.
|
||||
pub event_seq: LwwRegisterCrdt<f64>,
|
||||
/// Hex-encoded Ed25519 public key of the sled that recorded this event.
|
||||
pub sled_id: LwwRegisterCrdt<String>,
|
||||
/// Unix timestamp (seconds) when the transition fired.
|
||||
pub timestamp: LwwRegisterCrdt<f64>,
|
||||
/// Story ID of the work item that transitioned (e.g. `"42_story_foo"`).
|
||||
pub story_id: LwwRegisterCrdt<String>,
|
||||
/// Human-readable label of the stage before the transition.
|
||||
pub from_stage: LwwRegisterCrdt<String>,
|
||||
/// Human-readable label of the stage after the transition.
|
||||
pub to_stage: LwwRegisterCrdt<String>,
|
||||
/// String label of the `PipelineEvent` variant that triggered the transition.
|
||||
pub pipeline_event: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
/// CRDT sub-document representing a single pipeline work item with LWW fields for stage, agent, etc.
|
||||
|
||||
Reference in New Issue
Block a user