From 9ea1da86ceaaa419662516163634151fed0d4c2d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 1 Jun 2026 19:42:24 -0400 Subject: [PATCH 01/11] feat(scheduler): wire full crate surface and pin cron 0.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose processors, projections, queries, and pause/remove/resume deciders; fix Schedule→ScheduleEventSchedule conversion and align Cargo deps for a buildable trogon-scheduler binary and tests. --- .../crates/trogon-scheduler/Cargo.toml | 46 +- .../crates/trogon-scheduler/src/error.rs | 442 ++++++ .../crates/trogon-scheduler/src/lib.rs | 40 +- .../crates/trogon-scheduler/src/mocks.rs | 940 +++++++++++++ .../src/projections/schedules.rs | 1199 +++++++++++++++++ .../trogon-scheduler/src/queries/mod.rs | 7 + 6 files changed, 2658 insertions(+), 16 deletions(-) create mode 100644 rsworkspace/crates/trogon-scheduler/src/error.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/mocks.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/queries/mod.rs diff --git a/rsworkspace/crates/trogon-scheduler/Cargo.toml b/rsworkspace/crates/trogon-scheduler/Cargo.toml index ebdba6b52..4f1854a20 100644 --- a/rsworkspace/crates/trogon-scheduler/Cargo.toml +++ b/rsworkspace/crates/trogon-scheduler/Cargo.toml @@ -4,32 +4,45 @@ version = "0.1.0" edition = "2024" license = "Apache-2.0" publish = false +autotests = false -[lints] -workspace = true +[lints.rust] +warnings = "deny" +unexpected_cfgs = { level = "deny", check-cfg = ['cfg(coverage)'] } + +[lints.clippy] +all = "deny" +expect_used = "deny" +panic = "deny" +unwrap_used = "deny" + +[features] +test-support = ["trogon-decider-runtime/test-support", "trogon-nats/test-support"] [dependencies] thiserror = { workspace = true } +trogon-telemetry = { workspace = true } async-nats = { workspace = true, features = ["jetstream", "kv", "server_2_12"] } base64 = { workspace = true } buffa = { workspace = true } buffa-types = { workspace = true } bytes = { workspace = true } -chrono = { version = "0.4" } -chrono-tz = "0.10" +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = { workspace = true } cron = "0.15" futures = { workspace = true } opentelemetry = { workspace = true } -rrule = "0.14" +rrule = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } +tokio = { workspace = true, features = ["time", "signal", "rt-multi-thread", "macros", "sync"] } tracing = { workspace = true } tracing-opentelemetry = { workspace = true } -trogon-decider-runtime = { workspace = true } trogon-nats = { workspace = true } -trogon-std = { workspace = true } -trogonai-proto = { path = "../trogonai-proto", features = ["schedules"] } +trogon-decider-nats = { workspace = true } +trogon-decider-runtime = { workspace = true } +trogonai-proto = { workspace = true, features = ["schedules"] } +trogon-std = { workspace = true, features = ["uuid"] } uuid = { workspace = true, features = ["v5"] } [dev-dependencies] @@ -38,7 +51,16 @@ proptest = "1" testcontainers-modules = { version = "0.15", features = ["nats"] } time = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } -trogon-decider = { path = "../trogon-decider", features = ["test-support"] } -trogon-decider-nats = { path = "../trogon-decider-nats" } +trogon-decider = { workspace = true, features = ["test-support"] } +trogon-decider-runtime = { workspace = true, features = ["test-support"] } +trogon-decider-nats = { workspace = true } trogon-nats = { workspace = true, features = ["test-support"] } -trogon-std = { workspace = true, features = ["test-support"] } + +[[test]] +name = "schedule_unit" +path = "tests/schedule_unit.rs" +required-features = ["test-support"] + +[[test]] +name = "integration" +path = "tests/integration.rs" diff --git a/rsworkspace/crates/trogon-scheduler/src/error.rs b/rsworkspace/crates/trogon-scheduler/src/error.rs new file mode 100644 index 000000000..3f1e17817 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/error.rs @@ -0,0 +1,442 @@ +use trogon_decider_nats::{JetStreamStoreError, SnapshotStoreError}; +use trogon_decider_runtime::{StreamPosition, StreamWritePrecondition}; + +use crate::commands::domain::MessageHeadersError; + +type BoxError = Box; + +#[derive(Debug)] +pub enum SchedulerError { + Event { + context: &'static str, + source: BoxError, + }, + Kv { + context: &'static str, + source: BoxError, + }, + Lease { + context: &'static str, + source: BoxError, + }, + Schedule { + context: &'static str, + source: BoxError, + }, + ScheduleAlreadyExists { + id: String, + }, + ScheduleNotFound { + id: String, + }, + OptimisticConcurrencyConflict { + id: String, + expected: StreamWritePrecondition, + current_position: Option, + }, + Serde(serde_json::Error), + InvalidScheduleSpec { + source: ScheduleSpecError, + }, +} + +#[derive(Debug)] +pub enum ScheduleSpecError { + InvalidId { + id: String, + source: trogon_nats::SubjectTokenViolation, + }, + EverySecondsMustBePositive, + InvalidCronExpression { + expr: String, + source: BoxError, + }, + InvalidRRule { + rrule: String, + source: BoxError, + }, + InvalidRRuleDateTime { + field: &'static str, + value: String, + source: BoxError, + }, + RRuleHasNoNextOccurrence { + rrule: String, + }, + InvalidTimezone { + timezone: String, + }, + InvalidRoute { + route: String, + source: trogon_nats::SubjectTokenViolation, + }, + InvalidSamplingSource { + subject: String, + source: trogon_nats::SubjectTokenViolation, + }, + TtlMustBePositive, + InvalidHeaderName { + name: String, + }, + ReservedHeaderName { + name: String, + }, + InvalidHeaderValue { + name: String, + }, +} + +impl std::fmt::Display for SchedulerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Event { context, source } => write!(f, "Event error: {context}: {source}"), + Self::Kv { context, source } => write!(f, "KV error: {context}: {source}"), + Self::Lease { context, source } => write!(f, "Lease error: {context}: {source}"), + Self::Schedule { context, source } => { + write!(f, "Schedule error: {context}: {source}") + } + Self::ScheduleAlreadyExists { id } => write!(f, "Schedule '{id}' already exists"), + Self::ScheduleNotFound { id } => write!(f, "Schedule '{id}' not found"), + Self::OptimisticConcurrencyConflict { + id, + expected, + current_position, + } => match current_position { + Some(current_position) => write!( + f, + "OCC conflict for schedule '{id}': expected {expected:?}, current position is {current_position}" + ), + None => write!( + f, + "OCC conflict for schedule '{id}': expected {expected:?}, schedule has no current position" + ), + }, + Self::Serde(error) => write!(f, "Serialization error: {error}"), + Self::InvalidScheduleSpec { source } => write!(f, "Invalid schedule spec: {source}"), + } + } +} + +impl std::error::Error for SchedulerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Event { source, .. } => Some(source.as_ref()), + Self::Kv { source, .. } => Some(source.as_ref()), + Self::Lease { source, .. } => Some(source.as_ref()), + Self::Schedule { source, .. } => Some(source.as_ref()), + Self::Serde(error) => Some(error), + Self::InvalidScheduleSpec { source } => Some(source), + Self::ScheduleAlreadyExists { .. } + | Self::ScheduleNotFound { .. } + | Self::OptimisticConcurrencyConflict { .. } => None, + } + } +} + +impl SchedulerError { + pub fn event_source(context: &'static str, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Event { + context, + source: Box::new(source), + } + } + + pub fn kv_source(context: &'static str, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Kv { + context, + source: Box::new(source), + } + } + + pub fn lease_source(context: &'static str, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Lease { + context, + source: Box::new(source), + } + } + + pub fn schedule_source(context: &'static str, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Schedule { + context, + source: Box::new(source), + } + } + + pub fn invalid_schedule_spec(source: ScheduleSpecError) -> Self { + Self::InvalidScheduleSpec { source } + } +} + +impl std::fmt::Display for ScheduleSpecError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidId { id, source } => write!(f, "schedule id '{id}' is invalid: {source:?}"), + Self::EverySecondsMustBePositive => f.write_str("every_sec must be >= 1"), + Self::InvalidCronExpression { expr, source } => { + write!(f, "cron expression '{expr}' is invalid: {source}") + } + Self::InvalidRRule { rrule, source } => { + write!(f, "RRULE '{rrule}' is invalid: {source}") + } + Self::InvalidRRuleDateTime { field, value, source } => { + write!(f, "RRULE {field} timestamp '{value}' is invalid: {source}") + } + Self::RRuleHasNoNextOccurrence { rrule } => { + write!(f, "RRULE '{rrule}' has no next occurrence") + } + Self::InvalidTimezone { timezone } => { + write!(f, "timezone '{timezone}' is invalid") + } + Self::InvalidRoute { route, source } => { + write!(f, "route '{route}' is invalid: {source:?}") + } + Self::InvalidSamplingSource { subject, source } => { + write!(f, "sampling source '{subject}' is invalid: {source:?}") + } + Self::TtlMustBePositive => f.write_str("ttl_sec must be >= 1"), + Self::InvalidHeaderName { name } => write!(f, "header name '{name}' is invalid"), + Self::ReservedHeaderName { name } => { + write!(f, "header name '{name}' is reserved by the scheduler") + } + Self::InvalidHeaderValue { name } => { + write!(f, "header '{name}' contains an invalid value") + } + } + } +} + +impl std::error::Error for ScheduleSpecError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidId { source, .. } => Some(source), + Self::InvalidCronExpression { source, .. } => Some(source.as_ref()), + Self::InvalidRRule { source, .. } => Some(source.as_ref()), + Self::InvalidRRuleDateTime { source, .. } => Some(source.as_ref()), + Self::InvalidRoute { source, .. } => Some(source), + Self::InvalidSamplingSource { source, .. } => Some(source), + Self::EverySecondsMustBePositive + | Self::RRuleHasNoNextOccurrence { .. } + | Self::InvalidTimezone { .. } + | Self::TtlMustBePositive + | Self::InvalidHeaderName { .. } + | Self::ReservedHeaderName { .. } + | Self::InvalidHeaderValue { .. } => None, + } + } +} + +impl From for SchedulerError { + fn from(value: serde_json::Error) -> Self { + Self::Serde(value) + } +} + +impl From for SchedulerError { + fn from(value: trogonai_proto::scheduler::schedules::ScheduleEventPayloadError) -> Self { + Self::event_source("failed to encode or decode schedule event payload", value) + } +} + +impl From for ScheduleSpecError { + fn from(value: MessageHeadersError) -> Self { + match value { + MessageHeadersError::InvalidName { name } => Self::InvalidHeaderName { name }, + MessageHeadersError::InvalidValue { name } => Self::InvalidHeaderValue { name }, + } + } +} + +impl From> for SchedulerError +where + PayloadError: std::error::Error + Send + Sync + 'static, + SnapshotTypeError: std::error::Error + Send + Sync + 'static, +{ + fn from(value: SnapshotStoreError) -> Self { + match value { + SnapshotStoreError::Kv(source) => Self::kv_source("failed to access stream snapshot storage", source), + SnapshotStoreError::Codec(source) => { + Self::event_source("failed to encode or decode stream snapshot", source) + } + SnapshotStoreError::InvalidSnapshotKey { key } => { + Self::event_source("failed to decode stream snapshot key", std::io::Error::other(key)) + } + SnapshotStoreError::MissingCheckpointName { snapshot_type } => Self::event_source( + "failed to resolve stream snapshot checkpoint key", + std::io::Error::other(snapshot_type.to_string()), + ), + } + } +} + +impl + From> for SchedulerError +where + SnapshotPayloadError: std::error::Error + Send + Sync + 'static, + SnapshotTypeError: std::error::Error + Send + Sync + 'static, +{ + fn from(value: JetStreamStoreError) -> Self { + match value { + JetStreamStoreError::ResolveSubject(source) => source, + JetStreamStoreError::ReadStream(source) => { + Self::event_source("failed to read schedule stream while catching up command state", source) + } + JetStreamStoreError::AppendStream(source) => { + Self::event_source("failed to append schedule event batch", source) + } + JetStreamStoreError::Snapshot(source) => Self::from(source), + JetStreamStoreError::Codec(source) => source, + JetStreamStoreError::OptimisticConcurrencyConflict(source) => Self::OptimisticConcurrencyConflict { + id: source.stream_id, + expected: source.expected, + current_position: source.current_position, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trogon_decider_runtime::{StreamPosition, StreamWritePrecondition}; + use trogon_nats::SubjectTokenViolation; + + fn position(value: u64) -> StreamPosition { + StreamPosition::try_new(value).expect("test stream position must be non-zero") + } + + #[test] + fn invalid_schedule_spec_display_mentions_field() { + let error = SchedulerError::invalid_schedule_spec(ScheduleSpecError::TtlMustBePositive); + assert_eq!(error.to_string(), "Invalid schedule spec: ttl_sec must be >= 1"); + } + + #[test] + fn kv_source_preserves_source() { + let error = SchedulerError::kv_source("open bucket", std::io::Error::other("boom")); + assert_eq!(error.to_string(), "KV error: open bucket: boom"); + assert!(std::error::Error::source(&error).is_some()); + } + + #[test] + fn scheduler_error_display_and_sources_cover_remaining_variants() { + let event = SchedulerError::event_source("replay", std::io::Error::other("broken")); + assert_eq!(event.to_string(), "Event error: replay: broken"); + assert!(std::error::Error::source(&event).is_some()); + + let lease = SchedulerError::lease_source("renew", std::io::Error::other("lost")); + assert_eq!(lease.to_string(), "Lease error: renew: lost"); + assert!(std::error::Error::source(&lease).is_some()); + + let schedule = SchedulerError::schedule_source("upsert", std::io::Error::other("rejected")); + assert_eq!(schedule.to_string(), "Schedule error: upsert: rejected"); + assert!(std::error::Error::source(&schedule).is_some()); + + let already_exists = SchedulerError::ScheduleAlreadyExists { + id: "job-1".to_string(), + }; + assert_eq!(already_exists.to_string(), "Schedule 'job-1' already exists"); + assert!(std::error::Error::source(&already_exists).is_none()); + + let not_found = SchedulerError::ScheduleNotFound { + id: "missing".to_string(), + }; + assert_eq!(not_found.to_string(), "Schedule 'missing' not found"); + assert!(std::error::Error::source(¬_found).is_none()); + + let occ_missing = SchedulerError::OptimisticConcurrencyConflict { + id: "job-1".to_string(), + expected: StreamWritePrecondition::NoStream, + current_position: None, + }; + assert!(occ_missing.to_string().contains("schedule has no current position")); + assert!(std::error::Error::source(&occ_missing).is_none()); + + let occ_current = SchedulerError::OptimisticConcurrencyConflict { + id: "job-1".to_string(), + expected: StreamWritePrecondition::At(position(3)), + current_position: Some(position(4)), + }; + assert!(occ_current.to_string().contains("current position is 4")); + assert!(std::error::Error::source(&occ_current).is_none()); + + let serde_error: SchedulerError = serde_json::from_str::("{").unwrap_err().into(); + assert!(serde_error.to_string().starts_with("Serialization error:")); + assert!(std::error::Error::source(&serde_error).is_some()); + } + + #[test] + fn job_spec_error_display_and_sources_cover_remaining_variants() { + let invalid_id = ScheduleSpecError::InvalidId { + id: "".to_string(), + source: SubjectTokenViolation::Empty, + }; + assert!(invalid_id.to_string().contains("schedule id '' is invalid")); + assert!(std::error::Error::source(&invalid_id).is_some()); + + let every = ScheduleSpecError::EverySecondsMustBePositive; + assert_eq!(every.to_string(), "every_sec must be >= 1"); + + let invalid_cron = ScheduleSpecError::InvalidCronExpression { + expr: "bad cron".to_string(), + source: Box::new(std::io::Error::other("invalid fields")), + }; + assert!( + invalid_cron + .to_string() + .contains("cron expression 'bad cron' is invalid") + ); + assert!(std::error::Error::source(&invalid_cron).is_some()); + + let timezone = ScheduleSpecError::InvalidTimezone { + timezone: "Mars/Base".to_string(), + }; + assert_eq!(timezone.to_string(), "timezone 'Mars/Base' is invalid"); + assert!(std::error::Error::source(&timezone).is_none()); + + let route = ScheduleSpecError::InvalidRoute { + route: "agent.>".to_string(), + source: SubjectTokenViolation::InvalidCharacter('>'), + }; + assert!(route.to_string().contains("route 'agent.>' is invalid")); + assert!(std::error::Error::source(&route).is_some()); + + let sampling = ScheduleSpecError::InvalidSamplingSource { + subject: "sensors.>".to_string(), + source: SubjectTokenViolation::InvalidCharacter('>'), + }; + assert!(sampling.to_string().contains("sampling source 'sensors.>' is invalid")); + assert!(std::error::Error::source(&sampling).is_some()); + + let invalid_header_name = ScheduleSpecError::InvalidHeaderName { name: "\n".to_string() }; + assert_eq!(invalid_header_name.to_string(), "header name '\n' is invalid"); + + let reserved = ScheduleSpecError::ReservedHeaderName { + name: "Nats-Schedule".to_string(), + }; + assert_eq!( + reserved.to_string(), + "header name 'Nats-Schedule' is reserved by the scheduler" + ); + + let invalid_header_value = ScheduleSpecError::InvalidHeaderValue { + name: "x-kind".to_string(), + }; + assert_eq!( + invalid_header_value.to_string(), + "header 'x-kind' contains an invalid value" + ); + assert!(std::error::Error::source(&invalid_header_value).is_none()); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/lib.rs b/rsworkspace/crates/trogon-scheduler/src/lib.rs index e4dd5245b..a6d592622 100644 --- a/rsworkspace/crates/trogon-scheduler/src/lib.rs +++ b/rsworkspace/crates/trogon-scheduler/src/lib.rs @@ -1,11 +1,43 @@ -#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] +#![cfg_attr( + any(test, feature = "test-support"), + allow(clippy::expect_used, clippy::panic, clippy::unwrap_used) +)] + +//! Generic scheduling control plane backed by native NATS scheduled messages. pub mod commands; +pub mod config; +pub mod error; +pub mod kv; +pub mod nats; pub mod processor; +mod projections; +pub mod queries; +mod read_model; +pub mod store; pub(crate) mod telemetry; +#[cfg(any(test, feature = "test-support"))] +pub mod mocks; + pub use commands::{ - CreateSchedule, CreateScheduleDecideError, PauseSchedule, PauseScheduleError, RecordScheduleOccurrence, - RecordScheduleOccurrenceError, RemoveSchedule, RemoveScheduleError, ResumeSchedule, ResumeScheduleError, - ScheduleNextOccurrence, ScheduleNextOccurrenceError, + CreateSchedule, CreateScheduleDecideError, EvolveError, PauseSchedule, PauseScheduleError, + RecordScheduleOccurrence, RecordScheduleOccurrenceError, RemoveSchedule, RemoveScheduleError, ResumeSchedule, + ResumeScheduleError, ScheduleNextOccurrence, ScheduleNextOccurrenceError, +}; +pub use config::ScheduleWriteCondition; +pub use error::{ScheduleSpecError, SchedulerError}; +pub use queries::{ + GetSchedule, GetScheduleCommand, ListSchedules, ListSchedulesCommand, ScheduleId, ScheduleIdError, get_schedule, + list_schedules, +}; +pub use read_model::{ + MessageContent, MessageEnvelope, MessageHeaders, MessageHeadersError, Schedule, ScheduleDetails, + ScheduleEventDelivery, ScheduleEventSamplingSource, ScheduleEventSchedule, ScheduleEventStatus, +}; +pub use store::{Store, connect_store, open_command_snapshot_bucket}; +pub use trogon_decider_runtime::{CommandError, CommandResult, ExecutionResult, StreamWritePrecondition}; +pub use trogonai_proto::scheduler::schedules::{ + DeliveryKind, ScheduleEventCase, ScheduleEventPayloadError, ScheduleKind, ScheduleStatusKind, SourceKind, state_v1, + v1, }; diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs new file mode 100644 index 000000000..b1d26b00b --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -0,0 +1,940 @@ +use std::collections::{HashMap, HashSet}; +use std::num::NonZeroU64; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +use async_nats::jetstream::kv; +use buffa::MessageField; +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use trogon_decider_runtime::snapshot::Snapshot; +use trogon_decider_runtime::{ + AppendStreamRequest, AppendStreamResponse, Event, EventData, EventDecode, EventEncode, EventId, EventIdentity, + EventType, Headers, ReadFrom, ReadSnapshotRequest, ReadSnapshotResponse, ReadStreamRequest, ReadStreamResponse, + SnapshotPayloadData, SnapshotPayloadDecode, SnapshotPayloadEncode, SnapshotRead, SnapshotType, SnapshotWrite, + StreamAppend, StreamEvent, StreamPosition, StreamRead, StreamWritePrecondition, WriteSnapshotRequest, + WriteSnapshotResponse, +}; +use trogon_nats::lease::{ReleaseLease, RenewLease, TryAcquireLease}; +use trogon_std::{NowV7, UuidV7Generator}; + +use crate::{ + DeliveryKind, GetSchedule, ListSchedules, ResolvedSchedule, ScheduleEventCase, ScheduleKind, ScheduleStatusKind, + SourceKind, + config::{ScheduleWriteCondition, ScheduleWriteState}, + error::SchedulerError, + projections::{LoadAndWatchSchedulesResult, ScheduleWatchStream}, + read_model::{ + MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSamplingSource, + ScheduleEventSchedule, ScheduleEventStatus, + }, + traits::SchedulePublisher, + v1, +}; + +#[derive(Clone, Default)] +pub struct MockSchedulePublisher { + upserts: Arc>>, + removals: Arc>>, + active: Arc>>, +} + +impl MockSchedulePublisher { + pub fn new() -> Self { + Self::default() + } + + pub fn upserts(&self) -> Vec { + self.upserts.lock().unwrap().clone() + } + + pub fn removals(&self) -> Vec { + self.removals.lock().unwrap().clone() + } + + pub fn seed_active_schedule(&self, job_id: &str) { + self.active.lock().unwrap().insert(job_id.to_string()); + } +} + +impl SchedulePublisher for MockSchedulePublisher { + type Error = SchedulerError; + + async fn active_schedule_ids(&self) -> Result, Self::Error> { + Ok(self.active.lock().unwrap().clone()) + } + + async fn upsert_schedule(&self, job: &ResolvedSchedule) -> Result<(), Self::Error> { + self.upserts.lock().unwrap().push(job.schedule_subject().to_string()); + self.active.lock().unwrap().insert(job.id().to_string()); + Ok(()) + } + + async fn remove_schedule(&self, job_id: &str) -> Result<(), Self::Error> { + self.removals.lock().unwrap().push(job_id.to_string()); + self.active.lock().unwrap().remove(job_id); + Ok(()) + } +} + +#[derive(Clone)] +pub struct MockLeaderLock { + allow_acquire: Arc, + allow_renew: Arc, + next_revision: Arc, +} + +impl Default for MockLeaderLock { + fn default() -> Self { + Self { + allow_acquire: Arc::new(AtomicBool::new(true)), + allow_renew: Arc::new(AtomicBool::new(true)), + next_revision: Arc::new(AtomicU64::new(1)), + } + } +} + +impl MockLeaderLock { + pub fn new() -> Self { + Self::default() + } + + pub fn set_allow_acquire(&self, allowed: bool) { + self.allow_acquire.store(allowed, Ordering::SeqCst); + } + + pub fn set_allow_renew(&self, allowed: bool) { + self.allow_renew.store(allowed, Ordering::SeqCst); + } +} + +impl TryAcquireLease for MockLeaderLock { + type Error = kv::CreateError; + + async fn try_acquire(&self, _value: Bytes) -> Result { + if self.allow_acquire.load(Ordering::SeqCst) { + Ok(self.next_revision.fetch_add(1, Ordering::SeqCst)) + } else { + Err(kv::CreateError::new(kv::CreateErrorKind::AlreadyExists)) + } + } +} + +impl RenewLease for MockLeaderLock { + type Error = kv::UpdateError; + + async fn renew(&self, _value: Bytes, revision: u64) -> Result { + if self.allow_renew.load(Ordering::SeqCst) { + Ok(revision + 1) + } else { + Err(kv::UpdateError::new(kv::UpdateErrorKind::Other)) + } + } +} + +impl ReleaseLease for MockLeaderLock { + type Error = kv::DeleteError; + + async fn release(&self, _revision: u64) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[derive(Clone, Default)] +pub struct MockSchedulerStore { + schedules: Arc>>, + stream_positions: Arc>>, + events: Arc>>>, + command_snapshots: Arc>>>, +} + +#[derive(Clone)] +struct EncodedSnapshot { + position: StreamPosition, + payload: Vec, +} + +fn stream_position(value: u64) -> Result { + StreamPosition::try_new(value) + .map_err(|source| SchedulerError::event_source("mock stream position must be non-zero", source)) +} + +fn encode_event(event: &E) -> Event +where + E: EventType + EventIdentity + EventEncode, + ::Error: std::fmt::Debug, + ::Error: std::fmt::Debug, +{ + let id = event + .event_id() + .unwrap_or_else(|| EventId::new(UuidV7Generator.now_v7())); + Event { + id, + r#type: event.event_type().unwrap().to_string(), + content: event.encode().unwrap(), + headers: Headers::empty(), + } +} + +impl MockSchedulerStore { + pub fn new() -> Self { + Self::default() + } + + pub fn seed_schedule(&self, job: Schedule) { + let id = job.id.clone(); + let event = v1::ScheduleEvent { + event: Some( + v1::ScheduleCreated { + schedule_id: job.id.clone(), + status: MessageField::some(v1::ScheduleStatus { + kind: Some(match job.status { + ScheduleEventStatus::Scheduled => v1::schedule_status::Scheduled {}.into(), + ScheduleEventStatus::Paused => v1::schedule_status::Paused {}.into(), + }), + }), + schedule: MessageField::some(proto_schedule(&job.schedule)), + delivery: MessageField::some(proto_delivery(&job.delivery)), + message: MessageField::some(proto_message(&job.message)), + } + .into(), + ), + }; + + let initial_position = StreamPosition::new(NonZeroU64::MIN); + self.stream_positions + .lock() + .unwrap() + .insert(id.clone(), initial_position); + self.events + .lock() + .unwrap() + .insert(id.clone(), vec![encode_event(&event)]); + self.schedules.lock().unwrap().insert(id.clone(), job); + } + + pub(crate) fn read_command_snapshot( + &self, + stream_id: &(impl AsRef + ?Sized), + ) -> Result>, SchedulerError> + where + Payload: SnapshotPayloadDecode + SnapshotType, + Payload::Error: std::error::Error + Send + Sync + 'static, + { + self.command_snapshots + .lock() + .unwrap() + .get(Payload::SNAPSHOT_STREAM_PREFIX) + .and_then(|snapshots| snapshots.get(stream_id.as_ref()).cloned()) + .map(|snapshot| { + Payload::decode(SnapshotPayloadData::new(snapshot.payload.as_slice())) + .map(|payload| Snapshot::new(snapshot.position, payload)) + .map_err(|source| SchedulerError::event_source("failed to decode command snapshot payload", source)) + }) + .transpose() + } + + pub async fn get_schedule(&self, command: GetSchedule) -> Result, SchedulerError> { + Ok(self.schedules.lock().unwrap().get(command.id.as_str()).cloned()) + } + + pub async fn list_schedules(&self, _command: ListSchedules) -> Result, SchedulerError> { + Ok(self.schedules.lock().unwrap().values().cloned().collect()) + } + + pub async fn load_and_watch_schedules(&self) -> LoadAndWatchSchedulesResult { + let jobs = self.schedules.lock().unwrap().values().cloned().collect(); + Ok((jobs, Box::pin(futures::stream::pending()) as ScheduleWatchStream)) + } +} + +fn proto_schedule(schedule: &ScheduleEventSchedule) -> v1::Schedule { + use buffa_types::google::protobuf::{Duration, Timestamp}; + let kind = match schedule { + ScheduleEventSchedule::At { at } => { + let ts = Timestamp { + seconds: at.timestamp(), + nanos: at.timestamp_subsec_nanos() as i32, + ..Default::default() + }; + v1::schedule::At { + at: MessageField::some(ts), + } + .into() + } + ScheduleEventSchedule::Every { every_sec } => v1::schedule::Every { + every: MessageField::some(Duration { + seconds: *every_sec as i64, + ..Default::default() + }), + } + .into(), + ScheduleEventSchedule::Cron { expr, timezone } => v1::schedule::Cron { + expr: expr.clone(), + timezone: timezone + .as_deref() + .filter(|s| !s.is_empty()) + .map(|tz| trogonai_proto::google::r#type::TimeZone { + id: tz.to_string(), + ..Default::default() + }) + .map(MessageField::some) + .unwrap_or_else(MessageField::none), + } + .into(), + ScheduleEventSchedule::RRule { + dtstart, + rrule, + timezone, + rdate, + exdate, + } => { + let to_ts = |dt: &chrono::DateTime| Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + ..Default::default() + }; + v1::schedule::RRule { + dtstart: MessageField::some(to_ts(dtstart)), + rrule: rrule.clone(), + timezone: timezone + .as_deref() + .filter(|s| !s.is_empty()) + .map(|tz| trogonai_proto::google::r#type::TimeZone { + id: tz.to_string(), + ..Default::default() + }) + .map(MessageField::some) + .unwrap_or_else(MessageField::none), + rdate: rdate.iter().map(to_ts).collect(), + exdate: exdate.iter().map(to_ts).collect(), + } + .into() + } + }; + v1::Schedule { kind: Some(kind) } +} + +fn proto_delivery(delivery: &ScheduleEventDelivery) -> v1::Delivery { + use buffa_types::google::protobuf::Duration; + match delivery { + ScheduleEventDelivery::NatsMessage { + subject, + ttl_sec, + source, + } => v1::Delivery { + kind: Some( + v1::delivery::NatsMessage { + subject: subject.clone(), + ttl: ttl_sec + .map(|s| Duration { + seconds: s as i64, + ..Default::default() + }) + .map(MessageField::some) + .unwrap_or_else(MessageField::none), + source: source + .as_ref() + .map(proto_sampling_source) + .map(MessageField::some) + .unwrap_or_else(MessageField::none), + } + .into(), + ), + }, + } +} + +fn proto_sampling_source(source: &ScheduleEventSamplingSource) -> v1::delivery::nats_message::Source { + match source { + ScheduleEventSamplingSource::LatestFromSubject { subject } => v1::delivery::nats_message::Source { + kind: Some( + v1::delivery::nats_message::LatestFromSubject { + subject: subject.clone(), + } + .into(), + ), + }, + } +} + +fn proto_message(message: &MessageEnvelope) -> v1::Message { + v1::Message { + content: MessageField::some(trogonai_proto::content::v1alpha1::Content { + content_type: "application/json".to_string(), + data: message.content.as_str().as_bytes().to_vec(), + }), + headers: message + .headers + .as_slice() + .iter() + .map(|(name, value)| v1::Header { + name: name.clone(), + value: value.clone(), + }) + .collect(), + } +} + +fn schedule_read_model_from_proto(stream_id: &str, details: &v1::ScheduleCreated) -> Schedule { + Schedule { + id: stream_id.to_string(), + status: { + let is_paused = matches!( + details.status.as_option().and_then(|s| s.kind.as_ref()), + Some(ScheduleStatusKind::Paused(_)) + ); + if is_paused { + ScheduleEventStatus::Paused + } else { + ScheduleEventStatus::Scheduled + } + }, + schedule: details + .schedule + .as_option() + .map(schedule_from_proto) + .unwrap_or(ScheduleEventSchedule::Every { every_sec: 0 }), + delivery: details + .delivery + .as_option() + .map(delivery_from_proto) + .unwrap_or_else(|| ScheduleEventDelivery::NatsMessage { + subject: String::new(), + ttl_sec: None, + source: None, + }), + message: details + .message + .as_option() + .map(message_from_proto) + .unwrap_or_else(|| MessageEnvelope { + content: MessageContent::new(String::new()), + headers: MessageHeaders::default(), + }), + } +} + +fn schedule_from_proto(schedule: &v1::Schedule) -> ScheduleEventSchedule { + use buffa_types::google::protobuf::Timestamp; + use chrono::TimeZone; + let ts_to_dt = |ts: &Timestamp| -> chrono::DateTime { + chrono::Utc + .timestamp_opt(ts.seconds, ts.nanos as u32) + .single() + .unwrap_or_default() + }; + match schedule.kind.as_ref() { + Some(ScheduleKind::At(inner)) => ScheduleEventSchedule::At { + at: inner.at.as_option().map(ts_to_dt).unwrap_or_default(), + }, + Some(ScheduleKind::Every(inner)) => ScheduleEventSchedule::Every { + every_sec: inner.every.as_option().map(|d| d.seconds as u64).unwrap_or(0), + }, + Some(ScheduleKind::Cron(inner)) => ScheduleEventSchedule::Cron { + expr: inner.expr.clone(), + timezone: inner + .timezone + .as_option() + .map(|tz| tz.id.clone()) + .filter(|s| !s.is_empty()), + }, + Some(ScheduleKind::Rrule(inner)) => ScheduleEventSchedule::RRule { + dtstart: inner.dtstart.as_option().map(ts_to_dt).unwrap_or_default(), + rrule: inner.rrule.clone(), + timezone: inner + .timezone + .as_option() + .map(|tz| tz.id.clone()) + .filter(|s| !s.is_empty()), + rdate: inner.rdate.iter().map(ts_to_dt).collect(), + exdate: inner.exdate.iter().map(ts_to_dt).collect(), + }, + None => ScheduleEventSchedule::Every { every_sec: 0 }, + } +} + +fn delivery_from_proto(delivery: &v1::Delivery) -> ScheduleEventDelivery { + match delivery.kind.as_ref() { + Some(DeliveryKind::NatsMessage(inner)) => ScheduleEventDelivery::NatsMessage { + subject: inner.subject.clone(), + ttl_sec: inner.ttl.as_option().map(|d| d.seconds as u64), + source: inner.source.as_option().map(sampling_source_from_proto), + }, + None => ScheduleEventDelivery::NatsMessage { + subject: String::new(), + ttl_sec: None, + source: None, + }, + } +} + +fn sampling_source_from_proto(source: &v1::delivery::nats_message::Source) -> ScheduleEventSamplingSource { + match source.kind.as_ref() { + Some(SourceKind::LatestFromSubject(inner)) => ScheduleEventSamplingSource::LatestFromSubject { + subject: inner.subject.clone(), + }, + None => ScheduleEventSamplingSource::LatestFromSubject { subject: String::new() }, + } +} + +fn message_from_proto(message: &v1::Message) -> MessageEnvelope { + let content_str = message + .content + .as_option() + .map(|c| String::from_utf8_lossy(&c.data).into_owned()) + .unwrap_or_default(); + MessageEnvelope { + content: MessageContent::new(content_str), + headers: MessageHeaders::from_pairs( + message + .headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())), + ), + } +} + +impl StreamRead for MockSchedulerStore { + type Error = SchedulerError; + + async fn read_stream(&self, request: ReadStreamRequest<'_, str>) -> Result { + let stream_id = request.stream_id; + let from_sequence = match request.from { + ReadFrom::Beginning => 1, + ReadFrom::Position(position) => position.as_u64(), + }; + let current_position = self.stream_positions.lock().unwrap().get(stream_id).copied(); + let stream_events = self.events.lock().unwrap().get(stream_id).cloned().unwrap_or_default(); + + let mut recorded = Vec::new(); + for (index, event) in stream_events.into_iter().enumerate() { + let sequence = index as u64 + 1; + if sequence < from_sequence { + continue; + } + recorded.push(StreamEvent { + stream_id: stream_id.to_string(), + event, + stream_position: stream_position(sequence)?, + recorded_at: DateTime::::from_timestamp(1_700_000_000 + sequence as i64, 0).ok_or_else(|| { + SchedulerError::event_source( + "failed to build mocked recorded event timestamp", + std::io::Error::other(stream_id.to_string()), + ) + })?, + }); + } + Ok(ReadStreamResponse { + current_position, + events: recorded, + }) + } +} + +impl StreamAppend for MockSchedulerStore { + type Error = SchedulerError; + + async fn append_stream(&self, request: AppendStreamRequest<'_, str>) -> Result { + let stream_id = request.stream_id.to_string(); + let expected_state = request.stream_write_precondition; + let events = request.events; + let jobs = self.schedules.clone(); + let stream_positions = self.stream_positions.clone(); + let event_log = self.events.clone(); + + let mut jobs = jobs.lock().unwrap(); + let mut stream_positions = stream_positions.lock().unwrap(); + let mut stream_events = event_log.lock().unwrap(); + + let current_job = jobs.get(stream_id.as_str()).cloned(); + let current_position = stream_positions.get(stream_id.as_str()).copied(); + let write_state = ScheduleWriteState::new(current_position, current_position.is_some()); + match expected_state { + StreamWritePrecondition::Any => {} + StreamWritePrecondition::StreamExists if write_state.exists() => {} + StreamWritePrecondition::StreamExists => { + return Err(SchedulerError::OptimisticConcurrencyConflict { + id: stream_id.to_string(), + expected: StreamWritePrecondition::StreamExists, + current_position, + }); + } + StreamWritePrecondition::NoStream => { + ScheduleWriteCondition::MustNotExist.ensure(stream_id.as_str(), write_state)?; + } + StreamWritePrecondition::At(position) => { + ScheduleWriteCondition::MustBeAtPosition(position).ensure(stream_id.as_str(), write_state)?; + } + } + + let stored_events = stream_events.entry(stream_id.to_string()).or_default(); + let mut projected_schedule = current_job; + let mut raw_position = current_position.map(StreamPosition::as_u64).unwrap_or(0); + + for event_data in events { + let event = v1::ScheduleEvent::decode(EventData::new(&event_data.r#type, &event_data.content)).map_err( + |source| SchedulerError::event_source("failed to decode mocked schedule event payload", source), + )?; + raw_position += 1; + stored_events.push(event_data); + let Some(event) = event.into_decoded() else { + continue; + }; + match &event.event { + Some(ScheduleEventCase::ScheduleCreated(inner)) => { + projected_schedule = Some(schedule_read_model_from_proto(stream_id.as_str(), inner)); + } + Some(ScheduleEventCase::SchedulePaused(_)) => { + let mut job = projected_schedule.take().ok_or_else(|| { + SchedulerError::event_source( + "failed to project mocked schedule pause without current read model", + std::io::Error::other(stream_id.to_string()), + ) + })?; + job.status = crate::ScheduleEventStatus::Paused; + projected_schedule = Some(job); + } + Some(ScheduleEventCase::ScheduleResumed(_)) => { + let mut job = projected_schedule.take().ok_or_else(|| { + SchedulerError::event_source( + "failed to project mocked schedule resume without current read model", + std::io::Error::other(stream_id.to_string()), + ) + })?; + job.status = crate::ScheduleEventStatus::Scheduled; + projected_schedule = Some(job); + } + Some(ScheduleEventCase::ScheduleRemoved(_)) => { + projected_schedule = None; + } + None => { + return Err(SchedulerError::event_source( + "failed to project mocked schedule event without supported case", + std::io::Error::other("missing event case"), + )); + } + } + } + + let final_position = stream_position(raw_position)?; + stream_positions.insert(stream_id.to_string(), final_position); + if let Some(job) = projected_schedule { + jobs.insert(stream_id.to_string(), job); + } else { + jobs.remove(stream_id.as_str()); + } + Ok(AppendStreamResponse { + stream_position: final_position, + }) + } +} + +impl SnapshotRead for MockSchedulerStore +where + Payload: SnapshotPayloadDecode + SnapshotType + Send, + Payload::Error: std::error::Error + Send + Sync + 'static, +{ + type Error = SchedulerError; + + async fn read_snapshot( + &self, + request: ReadSnapshotRequest<'_, str>, + ) -> Result, Self::Error> { + self.read_command_snapshot(request.stream_id) + .map(|snapshot| ReadSnapshotResponse { snapshot }) + } +} + +impl SnapshotWrite for MockSchedulerStore +where + Payload: SnapshotPayloadEncode + SnapshotType + Send, + Payload::Error: std::error::Error + Send + Sync + 'static, +{ + type Error = SchedulerError; + + async fn write_snapshot( + &self, + request: WriteSnapshotRequest<'_, Payload, str>, + ) -> Result { + let snapshot = + EncodedSnapshot { + position: request.snapshot.position, + payload: request.snapshot.payload.encode().map_err(|source| { + SchedulerError::event_source("failed to encode command snapshot payload", source) + })?, + }; + self.command_snapshots + .lock() + .unwrap() + .entry(Payload::SNAPSHOT_STREAM_PREFIX.to_string()) + .or_default() + .insert(request.stream_id.to_string(), snapshot); + Ok(WriteSnapshotResponse) + } +} + +#[cfg(test)] +mod tests { + use trogon_decider_runtime::{CommandError, CommandExecution, ImmediateSnapshotTaskScheduler}; + + use super::*; + use crate::commands::domain as command_domain; + use crate::{ + CreateSchedule, GetSchedule, ListSchedules, MessageContent, MessageEnvelope, MessageHeaders, PauseSchedule, + RemoveSchedule, ResumeSchedule, Schedule, ScheduleEventDelivery, ScheduleEventSchedule, ScheduleEventStatus, + ScheduleId, ScheduleWriteCondition, + }; + + fn position(value: u64) -> StreamPosition { + StreamPosition::try_new(value).expect("test stream position must be non-zero") + } + use futures::StreamExt; + + fn command_schedule_id(id: &str) -> command_domain::ScheduleId { + command_domain::ScheduleId::parse(id).unwrap() + } + + fn base_schedule(id: &str) -> Schedule { + Schedule { + id: id.to_string(), + status: ScheduleEventStatus::Scheduled, + schedule: ScheduleEventSchedule::Every { every_sec: 30 }, + delivery: ScheduleEventDelivery::NatsMessage { + subject: "agent.run".to_string(), + ttl_sec: None, + source: None, + }, + message: MessageEnvelope { + content: MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + headers: MessageHeaders::default(), + }, + } + } + + fn command_base_schedule(id: &str) -> command_domain::Schedule { + command_domain::Schedule { + id: command_schedule_id(id), + status: command_domain::ScheduleStatus::Enabled, + schedule: command_domain::ScheduleSpec::every(30).unwrap(), + delivery: command_domain::Delivery::nats_event("agent.run").unwrap(), + message: command_domain::ScheduleMessage { + content: command_domain::MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + headers: command_domain::ScheduleHeaders::default(), + }, + } + } + + fn expected_schedule(id: &str) -> Schedule { + base_schedule(id) + } + + #[tokio::test] + async fn mock_schedule_publisher_tracks_active_schedules() { + let publisher = MockSchedulePublisher::new(); + publisher.seed_active_schedule("orphan"); + let schedule = expected_schedule("alpha"); + let details = v1::ScheduleCreated { + schedule_id: schedule.id.clone(), + status: MessageField::some(v1::ScheduleStatus { + kind: Some(match schedule.status { + ScheduleEventStatus::Scheduled => v1::schedule_status::Scheduled {}.into(), + ScheduleEventStatus::Paused => v1::schedule_status::Paused {}.into(), + }), + }), + schedule: MessageField::some(super::proto_schedule(&schedule.schedule)), + delivery: MessageField::some(super::proto_delivery(&schedule.delivery)), + message: MessageField::some(super::proto_message(&schedule.message)), + }; + let resolved = ResolvedSchedule::from_event("alpha", &details).unwrap(); + + let active = publisher.active_schedule_ids().await.unwrap(); + assert!(active.contains("orphan")); + + publisher.upsert_schedule(&resolved).await.unwrap(); + publisher.remove_schedule("orphan").await.unwrap(); + + assert_eq!(publisher.upserts(), vec!["scheduler.schedules.alpha"]); + assert_eq!(publisher.removals(), vec!["orphan"]); + assert!(publisher.active_schedule_ids().await.unwrap().contains("alpha")); + } + + #[tokio::test] + async fn mock_leader_lock_covers_success_and_failure_paths() { + let lock = MockLeaderLock::new(); + + let first = lock.try_acquire(Bytes::new()).await.unwrap(); + assert_eq!(first, 1); + assert_eq!(lock.renew(Bytes::new(), first).await.unwrap(), 2); + lock.release(first).await.unwrap(); + + lock.allow_acquire.store(false, Ordering::SeqCst); + assert_eq!( + lock.try_acquire(Bytes::new()).await.unwrap_err().kind(), + kv::CreateErrorKind::AlreadyExists + ); + + lock.allow_renew.store(false, Ordering::SeqCst); + assert_eq!( + lock.renew(Bytes::new(), 2).await.unwrap_err().kind(), + kv::UpdateErrorKind::Other + ); + } + + #[tokio::test] + async fn mock_scheduler_store_covers_crud_and_read_model_watch() { + let store = MockSchedulerStore::new(); + store.seed_schedule(base_schedule("seeded")); + + let seeded = store + .get_schedule(GetSchedule::new(ScheduleId::parse("seeded").unwrap())) + .await + .unwrap() + .unwrap(); + assert_eq!(seeded, expected_schedule("seeded")); + + CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + let alpha = store + .get_schedule(GetSchedule::new(ScheduleId::parse("alpha").unwrap())) + .await + .unwrap() + .unwrap(); + assert_eq!(alpha, expected_schedule("alpha")); + + CommandExecution::new(&store, &PauseSchedule::new(command_schedule_id("alpha"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + assert_eq!( + store + .get_schedule(GetSchedule::new(ScheduleId::parse("alpha").unwrap())) + .await + .unwrap() + .unwrap() + .status, + ScheduleEventStatus::Paused + ); + + let listed = store.list_schedules(ListSchedules).await.unwrap(); + assert_eq!(listed.len(), 2); + + let (watch_jobs, mut watcher) = store.load_and_watch_schedules().await.unwrap(); + assert_eq!(watch_jobs.len(), 2); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(5), watcher.next()) + .await + .is_err() + ); + + CommandExecution::new(&store, &RemoveSchedule::new(command_schedule_id("alpha"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + assert!( + store + .get_schedule(GetSchedule::new(ScheduleId::parse("alpha").unwrap())) + .await + .unwrap() + .is_none() + ); + + let deleted_error = CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap_err(); + assert!(matches!( + deleted_error, + CommandError::Decide(crate::CreateScheduleError::ScheduleDeleted { .. }) + )); + } + + #[tokio::test] + async fn mock_scheduler_store_rejects_invalid_specs_and_state_errors() { + let store = MockSchedulerStore::new(); + let invalid_error = serde_json::from_value::(serde_json::json!({ + "id": "bad", + "schedule": { "type": "every", "every_sec": 30 }, + "delivery": { + "type": "nats_event", + "route": "agent.run", + "source": { "type": "latest_from_subject", "subject": "sensors.>" } + }, + "content": "{\"kind\":\"heartbeat\"}" + })) + .unwrap_err(); + assert!(invalid_error.to_string().contains("sampling source")); + + CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + let same_state_error = CommandExecution::new(&store, &ResumeSchedule::new(command_schedule_id("alpha"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap_err(); + assert!(matches!( + same_state_error, + CommandError::Decide(crate::ResumeScheduleError::AlreadyActive { .. }) + )); + + let missing_error = CommandExecution::new(&store, &PauseSchedule::new(command_schedule_id("missing"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap_err(); + assert!(matches!( + missing_error, + CommandError::Decide(crate::PauseScheduleError::ScheduleNotFound { .. }) + )); + } + + #[test] + fn ensure_write_condition_covers_accept_and_conflict_paths() { + ScheduleWriteCondition::MustNotExist + .ensure("alpha", ScheduleWriteState::new(None, false)) + .unwrap(); + ScheduleWriteCondition::MustBeAtPosition(position(3)) + .ensure("alpha", ScheduleWriteState::new(Some(position(3)), true)) + .unwrap(); + + let error = ScheduleWriteCondition::MustNotExist + .ensure("alpha", ScheduleWriteState::new(Some(position(4)), true)) + .unwrap_err(); + assert!(matches!( + error, + SchedulerError::OptimisticConcurrencyConflict { + current_position: Some(_), + .. + } + )); + + let error = ScheduleWriteCondition::MustBeAtPosition(position(3)) + .ensure("alpha", ScheduleWriteState::new(Some(position(4)), true)) + .unwrap_err(); + assert!(matches!( + error, + SchedulerError::OptimisticConcurrencyConflict { + current_position: Some(_), + .. + } + )); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs new file mode 100644 index 000000000..38333b9c0 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs @@ -0,0 +1,1199 @@ +use std::collections::BTreeMap; +use std::pin::Pin; +use std::time::Duration; + +use async_nats::jetstream::{ + self, + consumer::{AckPolicy, DeliverPolicy, ReplayPolicy, pull}, + kv, +}; +use futures::{Stream, StreamExt}; +use trogon_decider_nats::record_stream_message; +use trogon_decider_runtime::{Event, EventData, EventDecode, StreamEvent, StreamPosition}; +use trogon_nats::SubjectTokenViolation; +use trogon_nats::jetstream::{JetStreamGetKeyValue, JetStreamGetStream}; + +use chrono::{TimeZone, Utc}; + +use crate::{ + DeliveryKind, ScheduleEventCase, ScheduleKind, ScheduleStatusKind, SourceKind, + error::SchedulerError, + kv::{EVENTS_SUBJECT_PREFIX, SCHEDULES_CHECKPOINT_KEY, open_events_stream, open_schedules_bucket}, + read_model::{ + MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSamplingSource, + ScheduleEventSchedule, ScheduleEventStatus, + }, + v1, +}; + +pub type ScheduleWatchStream = Pin + Send + 'static>>; +pub type LoadAndWatchSchedulesResult = Result<(Vec, ScheduleWatchStream), SchedulerError>; + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +pub enum ScheduleChange { + Put(Schedule), + Delete(String), +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq)] +pub enum ProjectionChange { + Upsert(Schedule), + Delete(String), +} + +#[derive(Debug, Clone, PartialEq)] +struct WatchedProjectionChange { + stream_id: String, + next_state: ScheduleStreamState, + change: Option, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq)] +pub enum ScheduleStreamState { + Initial, + Present(Schedule), + Deleted(String), +} + +#[derive(Debug)] +pub enum ScheduleTransitionError { + InvalidEventId { id: String, source: SubjectTokenViolation }, + MismatchedEventScheduleId { stream_id: String, schedule_id: String }, + MalformedEvent { context: &'static str }, + CannotAddExistingSchedule { id: String }, + CannotAddDeletedSchedule { id: String }, + MissingScheduleForStateChange { id: String }, + DeletedScheduleForStateChange { id: String }, + DeletedScheduleForRemoval { id: String }, +} + +pub const fn initial_state() -> ScheduleStreamState { + ScheduleStreamState::Initial +} + +pub fn apply( + stream_id: &str, + state: ScheduleStreamState, + event: &v1::ScheduleEvent, +) -> Result { + validate_event_schedule_id(stream_id).map_err(|source| ScheduleTransitionError::InvalidEventId { + id: stream_id.to_string(), + source, + })?; + validate_event_payload_schedule_id(stream_id, event)?; + + match (state, &event.event) { + (ScheduleStreamState::Initial, Some(ScheduleEventCase::ScheduleCreated(inner))) => { + Ok(ScheduleStreamState::Present(project_created_job(inner)?)) + } + (ScheduleStreamState::Initial, Some(ScheduleEventCase::SchedulePaused(_))) => { + Err(ScheduleTransitionError::MissingScheduleForStateChange { + id: stream_id.to_string(), + }) + } + (ScheduleStreamState::Initial, Some(ScheduleEventCase::ScheduleResumed(_))) => { + Err(ScheduleTransitionError::MissingScheduleForStateChange { + id: stream_id.to_string(), + }) + } + (ScheduleStreamState::Initial, Some(ScheduleEventCase::ScheduleRemoved(_))) => { + Ok(ScheduleStreamState::Deleted(stream_id.to_string())) + } + (ScheduleStreamState::Present(job), Some(ScheduleEventCase::ScheduleCreated(_))) => { + Err(ScheduleTransitionError::CannotAddExistingSchedule { id: job.id }) + } + (ScheduleStreamState::Present(mut job), Some(ScheduleEventCase::SchedulePaused(_))) => { + job.status = ScheduleEventStatus::Paused; + Ok(ScheduleStreamState::Present(job)) + } + (ScheduleStreamState::Present(mut job), Some(ScheduleEventCase::ScheduleResumed(_))) => { + job.status = ScheduleEventStatus::Scheduled; + Ok(ScheduleStreamState::Present(job)) + } + (ScheduleStreamState::Present(job), Some(ScheduleEventCase::ScheduleRemoved(_))) => { + Ok(ScheduleStreamState::Deleted(job.id)) + } + (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::ScheduleCreated(_))) => { + Err(ScheduleTransitionError::CannotAddDeletedSchedule { id }) + } + (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::SchedulePaused(_))) => { + Err(ScheduleTransitionError::DeletedScheduleForStateChange { id }) + } + (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::ScheduleResumed(_))) => { + Err(ScheduleTransitionError::DeletedScheduleForStateChange { id }) + } + (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::ScheduleRemoved(_))) => { + Err(ScheduleTransitionError::DeletedScheduleForRemoval { id }) + } + (_, None) => Err(ScheduleTransitionError::MalformedEvent { + context: "schedule event has no supported case", + }), + } +} + +fn validate_event_payload_schedule_id( + stream_id: &str, + event: &v1::ScheduleEvent, +) -> Result<(), ScheduleTransitionError> { + let Some(schedule_id) = event_schedule_id(event) else { + return Ok(()); + }; + validate_event_schedule_id(schedule_id).map_err(|source| ScheduleTransitionError::InvalidEventId { + id: schedule_id.to_string(), + source, + })?; + if schedule_id == stream_id { + Ok(()) + } else { + Err(ScheduleTransitionError::MismatchedEventScheduleId { + stream_id: stream_id.to_string(), + schedule_id: schedule_id.to_string(), + }) + } +} + +fn event_schedule_id(event: &v1::ScheduleEvent) -> Option<&str> { + match &event.event { + Some(ScheduleEventCase::ScheduleCreated(inner)) => Some(&inner.schedule_id), + Some(ScheduleEventCase::SchedulePaused(inner)) => Some(&inner.schedule_id), + Some(ScheduleEventCase::ScheduleResumed(inner)) => Some(&inner.schedule_id), + Some(ScheduleEventCase::ScheduleRemoved(inner)) => Some(&inner.schedule_id), + None => None, + } +} + +fn project_created_job(event: &v1::ScheduleCreated) -> Result { + let schedule = event + .schedule + .as_option() + .ok_or(ScheduleTransitionError::MalformedEvent { + context: "job details has no schedule", + })?; + let delivery = event + .delivery + .as_option() + .ok_or(ScheduleTransitionError::MalformedEvent { + context: "job details has no delivery", + })?; + let message = event + .message + .as_option() + .ok_or(ScheduleTransitionError::MalformedEvent { + context: "job details has no message", + })?; + Ok(Schedule { + id: event.schedule_id.to_string(), + status: project_status(event.status.as_option()), + schedule: project_schedule(schedule)?, + delivery: project_delivery(delivery)?, + message: project_message(message), + }) +} + +fn project_status(status: Option<&v1::ScheduleStatus>) -> ScheduleEventStatus { + if matches!( + status.and_then(|s| s.kind.as_ref()), + Some(ScheduleStatusKind::Paused(_)) + ) { + ScheduleEventStatus::Paused + } else { + ScheduleEventStatus::Scheduled + } +} + +fn timestamp_to_datetime(ts: &buffa_types::google::protobuf::Timestamp) -> chrono::DateTime { + Utc.timestamp_opt(ts.seconds, ts.nanos as u32) + .single() + .unwrap_or_default() +} + +fn project_schedule(schedule: &v1::Schedule) -> Result { + match schedule.kind.as_ref() { + Some(ScheduleKind::At(inner)) => { + let at = inner.at.as_option().map(timestamp_to_datetime).unwrap_or_default(); + Ok(ScheduleEventSchedule::At { at }) + } + Some(ScheduleKind::Every(inner)) => { + let every_sec = inner.every.as_option().map(|d| d.seconds as u64).unwrap_or(0); + Ok(ScheduleEventSchedule::Every { every_sec }) + } + Some(ScheduleKind::Cron(inner)) => Ok(ScheduleEventSchedule::Cron { + expr: inner.expr.clone(), + timezone: inner + .timezone + .as_option() + .map(|tz| tz.id.clone()) + .filter(|s| !s.is_empty()), + }), + Some(ScheduleKind::Rrule(inner)) => Ok(ScheduleEventSchedule::RRule { + dtstart: inner.dtstart.as_option().map(timestamp_to_datetime).unwrap_or_default(), + rrule: inner.rrule.clone(), + timezone: inner + .timezone + .as_option() + .map(|tz| tz.id.clone()) + .filter(|s| !s.is_empty()), + rdate: inner.rdate.iter().map(timestamp_to_datetime).collect(), + exdate: inner.exdate.iter().map(timestamp_to_datetime).collect(), + }), + None => Err(ScheduleTransitionError::MalformedEvent { + context: "job schedule has no supported case", + }), + } +} + +fn project_delivery(delivery: &v1::Delivery) -> Result { + match delivery.kind.as_ref() { + Some(DeliveryKind::NatsMessage(inner)) => Ok(ScheduleEventDelivery::NatsMessage { + subject: inner.subject.clone(), + ttl_sec: inner.ttl.as_option().map(|d| d.seconds as u64), + source: inner.source.as_option().map(project_sampling_source).transpose()?, + }), + None => Err(ScheduleTransitionError::MalformedEvent { + context: "job delivery has no supported case", + }), + } +} + +fn project_sampling_source( + source: &v1::delivery::nats_message::Source, +) -> Result { + match source.kind.as_ref() { + Some(SourceKind::LatestFromSubject(inner)) => Ok(ScheduleEventSamplingSource::LatestFromSubject { + subject: inner.subject.clone(), + }), + None => Err(ScheduleTransitionError::MalformedEvent { + context: "job sampling source has no supported case", + }), + } +} + +fn project_message(message: &v1::Message) -> MessageEnvelope { + let content_str = message + .content + .as_option() + .map(|c| String::from_utf8_lossy(&c.data).into_owned()) + .unwrap_or_default(); + MessageEnvelope { + content: MessageContent::new(content_str), + headers: MessageHeaders::from_pairs( + message + .headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())), + ), + } +} + +pub fn projection_change(before: &ScheduleStreamState, after: &ScheduleStreamState) -> Option { + match (before, after) { + (ScheduleStreamState::Initial, ScheduleStreamState::Initial) => None, + (_, ScheduleStreamState::Present(spec)) => Some(ProjectionChange::Upsert(spec.clone())), + (ScheduleStreamState::Present(spec), ScheduleStreamState::Initial | ScheduleStreamState::Deleted(_)) => { + Some(ProjectionChange::Delete(spec.id.to_string())) + } + (ScheduleStreamState::Initial, ScheduleStreamState::Deleted(_)) + | (ScheduleStreamState::Deleted(_), ScheduleStreamState::Initial) + | (ScheduleStreamState::Deleted(_), ScheduleStreamState::Deleted(_)) => None, + } +} + +impl ScheduleStreamState { + pub fn into_job(self) -> Option { + match self { + Self::Initial => None, + Self::Deleted(_) => None, + Self::Present(job) => Some(job), + } + } +} + +impl From for ScheduleStreamState { + fn from(job: Schedule) -> Self { + Self::Present(job) + } +} + +impl std::fmt::Display for ScheduleTransitionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidEventId { id, .. } => write!(f, "schedule event id '{id}' is invalid"), + Self::MismatchedEventScheduleId { stream_id, schedule_id } => { + write!( + f, + "schedule event id '{schedule_id}' does not match stream id '{stream_id}'" + ) + } + Self::MalformedEvent { context } => write!(f, "schedule event is malformed: {context}"), + Self::CannotAddExistingSchedule { id } => write!(f, "job '{id}' already exists"), + Self::CannotAddDeletedSchedule { id } => { + write!(f, "job '{id}' was deleted and cannot be added again") + } + Self::MissingScheduleForStateChange { id } => { + write!(f, "missing job for state change '{id}'") + } + Self::DeletedScheduleForStateChange { id } => { + write!(f, "deleted schedule '{id}' cannot change state") + } + Self::DeletedScheduleForRemoval { id } => { + write!(f, "job '{id}' was already deleted") + } + } + } +} + +impl std::error::Error for ScheduleTransitionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidEventId { source, .. } => Some(source), + Self::MismatchedEventScheduleId { .. } + | Self::MalformedEvent { .. } + | Self::CannotAddExistingSchedule { .. } + | Self::CannotAddDeletedSchedule { .. } + | Self::MissingScheduleForStateChange { .. } + | Self::DeletedScheduleForStateChange { .. } + | Self::DeletedScheduleForRemoval { .. } => None, + } + } +} + +pub async fn load_and_watch_schedules(js: &J) -> LoadAndWatchSchedulesResult +where + J: JetStreamGetKeyValue + JetStreamGetStream, +{ + let stream: jetstream::stream::Stream = open_events_stream(js).await?; + let info = stream + .get_info() + .await + .map_err(|source| SchedulerError::event_source("failed to query events stream info", source))?; + let last_sequence = info.state.last_sequence; + let initial_jobs = rebuild_jobs_from_stream(&stream, info.state.first_sequence, last_sequence).await?; + rewrite_schedules_projection(js, &initial_jobs).await?; + let consumer = stream + .create_consumer(event_watch_consumer_config(next_watch_start_sequence(last_sequence))) + .await + .map_err(|source| SchedulerError::event_source("failed to create schedule event watch consumer", source))?; + let subscriber = consumer + .messages() + .await + .map_err(|source| SchedulerError::event_source("failed to open schedule event watch stream", source))?; + + let kv: kv::Store = open_schedules_bucket(js).await?; + let state = initial_jobs + .iter() + .cloned() + .map(|job| (job.id.to_string(), ScheduleStreamState::Present(job))) + .collect::>(); + let watcher: ScheduleWatchStream = Box::pin(futures::stream::unfold( + (state, subscriber, kv), + |(mut state, mut subscriber, kv)| async move { + loop { + let result = subscriber.next().await?; + let message = match result { + Ok(message) => message, + Err(error) => { + tracing::error!(error = %error, "Failed to read schedule event from watch consumer"); + continue; + } + }; + let Some(projection_change) = prepare_watched_projection_change(&state, &message) else { + ack_watch_message(&message).await; + continue; + }; + + let WatchedProjectionChange { + stream_id, + next_state, + change, + } = projection_change; + + if let Some(change) = change.as_ref() + && let Err(error) = apply_projection_change(&kv, change).await + { + tracing::error!(error = %error, "Failed to update projected schedules state from event"); + nak_watch_message(&message).await; + continue; + } + + commit_watched_projection_state(&mut state, stream_id, next_state); + ack_watch_message(&message).await; + if let Some(change) = change { + return Some((change_from_projection_change(change), (state, subscriber, kv))); + } + } + }, + )); + + Ok((initial_jobs, watcher)) +} + +pub(crate) async fn catch_up_schedules_read_model(js: &J) -> Result<(), SchedulerError> +where + J: JetStreamGetKeyValue + JetStreamGetStream, +{ + let stream: jetstream::stream::Stream = open_events_stream(js).await?; + let info = stream.get_info().await.map_err(|source| { + SchedulerError::event_source( + "failed to query events stream info for schedules read-model catch-up", + source, + ) + })?; + if info.state.messages == 0 { + return Ok(()); + } + + let bucket = open_schedules_bucket(js).await?; + let checkpoint = read_read_model_checkpoint(&bucket).await?; + if checkpoint >= info.state.last_sequence { + return Ok(()); + } + + let mut states = read_model_state_map(&bucket).await?; + let start = checkpoint.max(info.state.first_sequence.saturating_sub(1)) + 1; + + let consumer = stream + .create_consumer(event_replay_consumer_config(start)) + .await + .map_err(|source| { + SchedulerError::event_source("failed to create schedules read-model catch-up consumer", source) + })?; + let mut messages = consumer.messages().await.map_err(|source| { + SchedulerError::event_source("failed to open schedules read-model catch-up stream", source) + })?; + + while let Some(message) = messages.next().await { + let message = message.map_err(|source| { + SchedulerError::event_source( + "failed to read schedule event during schedules read-model catch-up", + source, + ) + })?; + let sequence = event_message_sequence(&message, "failed to read schedules read-model catch-up event metadata")?; + if sequence > info.state.last_sequence { + break; + } + let reached_tail = sequence >= info.state.last_sequence; + let event = decode_recorded_watch_message(&message)?; + let data = event.decode::().map_err(|source| { + SchedulerError::event_source( + "failed to decode schedule event during schedules read-model catch-up", + source, + ) + })?; + let Some(data) = data.into_decoded() else { + write_read_model_checkpoint(&bucket, sequence).await?; + if reached_tail { + break; + } + continue; + }; + let stream_id = schedule_id_from_event_subject(event.stream_id())?; + if let Some(change) = apply_event_to_read_model_state(&mut states, &stream_id, &data)? { + apply_projection_change(&bucket, &change).await?; + } + write_read_model_checkpoint(&bucket, sequence).await?; + if reached_tail { + break; + } + } + + Ok(()) +} + +pub(crate) async fn project_appended_events( + bucket: &kv::Store, + job_id: &str, + events: &[Event], + final_position: StreamPosition, +) -> Result<(), SchedulerError> { + if events.is_empty() { + return Ok(()); + } + validate_event_schedule_id(job_id).map_err(|source| { + SchedulerError::invalid_schedule_spec(crate::ScheduleSpecError::InvalidId { + id: job_id.to_string(), + source, + }) + })?; + + let mut states = BTreeMap::new(); + if let Some(job) = read_projected_schedule(bucket, job_id).await? { + states.insert(job_id.to_string(), ScheduleStreamState::from(job)); + } + + for event in events { + let decoded = v1::ScheduleEvent::decode(EventData::new(&event.r#type, &event.content)).map_err(|source| { + SchedulerError::event_source("failed to decode schedule event for schedules read model", source) + })?; + let Some(decoded) = decoded.into_decoded() else { + continue; + }; + if let Some(change) = apply_event_to_read_model_state(&mut states, job_id, &decoded)? { + apply_projection_change(bucket, &change).await?; + } + } + maybe_advance_read_model_checkpoint(bucket, final_position.as_u64()).await +} + +async fn rewrite_schedules_projection(js: &J, jobs: &[Schedule]) -> Result<(), SchedulerError> +where + J: JetStreamGetKeyValue, +{ + let kv: kv::Store = open_schedules_bucket(js).await?; + let desired_ids = jobs + .iter() + .map(|job| job.id.as_str()) + .collect::>(); + let mut keys = kv + .keys() + .await + .map_err(|source| SchedulerError::kv_source("failed to list projection keys", source))?; + + while let Some(result) = keys.next().await { + let key = result.map_err(|source| SchedulerError::kv_source("failed to read projection key", source))?; + if is_read_model_metadata_key(&key) { + continue; + } + if desired_ids.contains(key.as_str()) { + continue; + } + kv.delete(key) + .await + .map_err(|source| SchedulerError::kv_source("failed to delete stale projected job state", source))?; + } + + for job in jobs { + let value = serde_json::to_vec(job)?; + kv.put(job.id.to_string(), value.into()) + .await + .map_err(|source| SchedulerError::kv_source("failed to write projected job state", source))?; + } + + Ok(()) +} + +async fn rebuild_jobs_from_stream( + stream: &jetstream::stream::Stream, + first_sequence: u64, + last_sequence: u64, +) -> Result, SchedulerError> { + let mut states = BTreeMap::new(); + if last_sequence == 0 || first_sequence == 0 || first_sequence > last_sequence { + return Ok(Vec::new()); + } + + let consumer = stream + .create_consumer(event_replay_consumer_config(first_sequence)) + .await + .map_err(|source| { + SchedulerError::event_source("failed to create schedule projection replay consumer", source) + })?; + let mut messages = consumer + .messages() + .await + .map_err(|source| SchedulerError::event_source("failed to open schedule projection replay stream", source))?; + + while let Some(message) = messages.next().await { + let message = message + .map_err(|source| SchedulerError::event_source("failed to read schedule event from stream", source))?; + let sequence = event_message_sequence(&message, "failed to read schedule event metadata")?; + if sequence > last_sequence { + break; + } + let reached_tail = sequence >= last_sequence; + let event = decode_recorded_watch_message(&message)?; + let data = event.decode::().map_err(|source| { + SchedulerError::event_source("failed to decode recorded schedule event payload", source) + })?; + let Some(data) = data.into_decoded() else { + if reached_tail { + break; + } + continue; + }; + let stream_id = schedule_id_from_event_subject(event.stream_id())?; + apply_event_to_read_model_state(&mut states, &stream_id, &data)?; + if reached_tail { + break; + } + } + + Ok(states.into_values().filter_map(ScheduleStreamState::into_job).collect()) +} + +fn decode_recorded_job_event( + message: async_nats::jetstream::message::StreamMessage, +) -> Result { + let stream_id = message.subject.to_string(); + record_stream_message(message, stream_id) + .map_err(|source| SchedulerError::event_source("failed to decode stored schedule event", source)) +} + +fn decode_recorded_watch_message(message: &async_nats::jetstream::Message) -> Result { + let stream_message = + async_nats::jetstream::message::StreamMessage::try_from(message.message.clone()).map_err(|source| { + SchedulerError::event_source("failed to reconstruct stream message from watch delivery", source) + })?; + + decode_recorded_job_event(stream_message) +} + +fn next_watch_start_sequence(last_sequence: u64) -> u64 { + last_sequence.saturating_add(1).max(1) +} + +fn event_watch_consumer_config(start_sequence: u64) -> pull::Config { + pull::Config { + deliver_policy: DeliverPolicy::ByStartSequence { start_sequence }, + ack_policy: AckPolicy::Explicit, + replay_policy: ReplayPolicy::Instant, + inactive_threshold: Duration::from_secs(30), + ..Default::default() + } +} + +fn event_replay_consumer_config(start_sequence: u64) -> pull::OrderedConfig { + pull::OrderedConfig { + deliver_policy: DeliverPolicy::ByStartSequence { start_sequence }, + replay_policy: ReplayPolicy::Instant, + ..Default::default() + } +} + +fn prepare_watched_projection_change( + state: &BTreeMap, + message: &jetstream::Message, +) -> Option { + let event = match decode_recorded_watch_message(message) { + Ok(event) => event, + Err(error) => { + tracing::error!(error = %error, "Failed to decode schedule event from watcher"); + return None; + } + }; + + let data = match event.decode::() { + Ok(data) => data, + Err(error) => { + tracing::error!(error = %error, "Failed to decode watched schedule event payload"); + return None; + } + }; + let data = data.into_decoded()?; + + let stream_id = match schedule_id_from_event_subject(event.stream_id()) { + Ok(stream_id) => stream_id, + Err(error) => { + tracing::error!(error = %error, "Failed to derive watched schedule stream id from subject"); + return None; + } + }; + + prepare_projection_change(state, stream_id.as_str(), &data) +} + +fn prepare_projection_change( + state: &BTreeMap, + stream_id: &str, + event: &v1::ScheduleEvent, +) -> Option { + let current = state.get(stream_id).cloned().unwrap_or_else(initial_state); + let next = match apply(stream_id, current.clone(), event) + .map_err(|error| SchedulerError::event_source("failed to apply watched schedule event to stream state", error)) + { + Ok(next) => next, + Err(error) => { + tracing::error!(error = %error, "Failed to apply schedule event to current state"); + return None; + } + }; + let change = projection_change(¤t, &next); + + Some(WatchedProjectionChange { + stream_id: stream_id.to_string(), + next_state: next, + change, + }) +} + +fn commit_watched_projection_state( + state: &mut BTreeMap, + stream_id: String, + next: ScheduleStreamState, +) { + match next { + ScheduleStreamState::Present(_) | ScheduleStreamState::Deleted(_) => { + state.insert(stream_id, next); + } + ScheduleStreamState::Initial => { + state.remove(stream_id.as_str()); + } + } +} + +async fn ack_watch_message(message: &jetstream::Message) { + if let Err(error) = message.ack().await { + tracing::error!(error = %error, "Failed to acknowledge watched schedule event"); + } +} + +async fn nak_watch_message(message: &jetstream::Message) { + if let Err(error) = message.ack_with(jetstream::AckKind::Nak(None)).await { + tracing::error!(error = %error, "Failed to negatively acknowledge watched schedule event"); + } +} + +fn event_message_sequence(message: &jetstream::Message, context: &'static str) -> Result { + message + .info() + .map(|info| info.stream_sequence) + .map_err(|source| SchedulerError::event_source(context, std::io::Error::other(source.to_string()))) +} + +fn is_read_model_metadata_key(key: &str) -> bool { + key == SCHEDULES_CHECKPOINT_KEY +} + +async fn read_projected_schedule(bucket: &kv::Store, id: &str) -> Result, SchedulerError> { + let Some(entry) = bucket + .entry(id.to_string()) + .await + .map_err(|source| SchedulerError::kv_source("failed to read projected schedule", source))? + else { + return Ok(None); + }; + + serde_json::from_slice(&entry.value) + .map(Some) + .map_err(SchedulerError::from) +} + +async fn read_model_state_map(bucket: &kv::Store) -> Result, SchedulerError> { + let mut keys = bucket + .keys() + .await + .map_err(|source| SchedulerError::kv_source("failed to list schedules read-model keys", source))?; + let mut states = BTreeMap::new(); + + while let Some(result) = keys.next().await { + let key = + result.map_err(|source| SchedulerError::kv_source("failed to read schedules read-model key", source))?; + if is_read_model_metadata_key(&key) { + continue; + } + if let Some(job) = read_projected_schedule(bucket, &key).await? { + states.insert(key, ScheduleStreamState::Present(job)); + } + } + + Ok(states) +} + +async fn read_read_model_checkpoint(bucket: &kv::Store) -> Result { + let Some(entry) = bucket + .entry(SCHEDULES_CHECKPOINT_KEY.to_string()) + .await + .map_err(|source| SchedulerError::kv_source("failed to read schedules read-model checkpoint", source))? + else { + return Ok(0); + }; + + String::from_utf8(entry.value.to_vec()) + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + SchedulerError::kv_source( + "failed to decode schedules read-model checkpoint", + std::io::Error::other(SCHEDULES_CHECKPOINT_KEY), + ) + }) +} + +async fn write_read_model_checkpoint(bucket: &kv::Store, sequence: u64) -> Result<(), SchedulerError> { + bucket + .put(SCHEDULES_CHECKPOINT_KEY.to_string(), sequence.to_string().into()) + .await + .map(|_| ()) + .map_err(|source| SchedulerError::kv_source("failed to write schedules read-model checkpoint", source)) +} + +async fn maybe_advance_read_model_checkpoint(bucket: &kv::Store, sequence: u64) -> Result<(), SchedulerError> { + let current = read_read_model_checkpoint(bucket).await?; + if current != sequence.saturating_sub(1) { + return Ok(()); + } + + write_read_model_checkpoint(bucket, sequence).await +} + +async fn apply_projection_change(kv: &kv::Store, change: &ProjectionChange) -> Result<(), SchedulerError> { + match change { + ProjectionChange::Upsert(job) => { + let value = serde_json::to_vec(job)?; + kv.put(job.id.to_string(), value.into()) + .await + .map_err(|source| SchedulerError::kv_source("failed to store projected job state", source))?; + } + ProjectionChange::Delete(id) => { + kv.delete(id.clone()) + .await + .map_err(|source| SchedulerError::kv_source("failed to delete projected job state", source))?; + } + } + + Ok(()) +} + +fn change_from_projection_change(change: ProjectionChange) -> ScheduleChange { + match change { + ProjectionChange::Upsert(job) => ScheduleChange::Put(job), + ProjectionChange::Delete(id) => ScheduleChange::Delete(id), + } +} + +fn apply_event_to_read_model_state( + states: &mut BTreeMap, + stream_id: &str, + event: &v1::ScheduleEvent, +) -> Result, SchedulerError> { + let current_state = states.get(stream_id).cloned().unwrap_or_else(initial_state); + let next_state = apply(stream_id, current_state.clone(), event).map_err(|source| { + SchedulerError::event_source("failed to apply schedule event to schedules read model", source) + })?; + let change = projection_change(¤t_state, &next_state); + + match next_state.clone() { + ScheduleStreamState::Present(_) | ScheduleStreamState::Deleted(_) => { + states.insert(stream_id.to_string(), next_state); + } + ScheduleStreamState::Initial => { + states.remove(stream_id); + } + } + + Ok(change) +} + +fn schedule_id_from_event_subject(subject: &str) -> Result { + let raw_id = subject.strip_prefix(EVENTS_SUBJECT_PREFIX).ok_or_else(|| { + SchedulerError::event_source( + "failed to derive schedule stream id from event subject", + std::io::Error::other(subject.to_string()), + ) + })?; + + validate_event_schedule_id(raw_id) + .map(|()| raw_id.to_string()) + .map_err(|source| { + SchedulerError::invalid_schedule_spec(crate::ScheduleSpecError::InvalidId { + id: raw_id.to_string(), + source, + }) + }) +} + +fn validate_event_schedule_id(id: &str) -> Result<(), SubjectTokenViolation> { + trogon_nats::NatsToken::new(id).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use buffa::MessageField; + use buffa_types::google::protobuf::{Duration, Timestamp}; + use chrono::{DateTime, Utc}; + + use super::*; + use crate::v1; + use crate::{ + MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSchedule, + ScheduleEventStatus, + }; + + fn timestamp_from_str(rfc3339: &str) -> Timestamp { + let dt = DateTime::parse_from_rfc3339(rfc3339).unwrap(); + Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + ..Default::default() + } + } + + fn expected_schedule(id: &str) -> Schedule { + Schedule { + id: id.to_string(), + status: ScheduleEventStatus::Scheduled, + schedule: ScheduleEventSchedule::Every { every_sec: 30 }, + delivery: ScheduleEventDelivery::NatsMessage { + subject: "agent.run".to_string(), + ttl_sec: None, + source: None, + }, + message: MessageEnvelope { + content: MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + headers: MessageHeaders::default(), + }, + } + } + + fn added_event(id: &str) -> v1::ScheduleEvent { + v1::ScheduleEvent { + event: Some(proto_job_created(id).into()), + } + } + + fn rrule_added_event(id: &str) -> v1::ScheduleEvent { + let mut created = proto_job_created(id); + created.schedule = MessageField::some(v1::Schedule { + kind: Some( + v1::schedule::RRule { + dtstart: MessageField::some(timestamp_from_str("2026-05-24T09:00:00+00:00")), + rrule: "FREQ=WEEKLY;BYDAY=MO".to_string(), + timezone: MessageField::some(trogonai_proto::google::r#type::TimeZone { + id: "UTC".to_string(), + ..Default::default() + }), + rdate: vec![timestamp_from_str("2026-05-26T09:00:00+00:00")], + exdate: vec![timestamp_from_str("2026-06-01T09:00:00+00:00")], + } + .into(), + ), + }); + v1::ScheduleEvent { + event: Some(created.into()), + } + } + + fn proto_job_created(id: &str) -> v1::ScheduleCreated { + v1::ScheduleCreated { + schedule_id: id.to_string(), + status: MessageField::some(v1::ScheduleStatus { + kind: Some(v1::schedule_status::Scheduled {}.into()), + }), + schedule: MessageField::some(v1::Schedule { + kind: Some( + v1::schedule::Every { + every: MessageField::some(Duration { + seconds: 30, + ..Default::default() + }), + } + .into(), + ), + }), + delivery: MessageField::some(v1::Delivery { + kind: Some( + v1::delivery::NatsMessage { + subject: "agent.run".to_string(), + ttl: MessageField::none(), + source: MessageField::none(), + } + .into(), + ), + }), + message: MessageField::some(v1::Message { + content: MessageField::some(trogonai_proto::content::v1alpha1::Content { + content_type: "application/json".to_string(), + data: r#"{"kind":"heartbeat"}"#.as_bytes().to_vec(), + }), + headers: Vec::new(), + }), + } + } + + fn paused_event(id: &str) -> v1::ScheduleEvent { + v1::ScheduleEvent { + event: Some( + v1::SchedulePaused { + schedule_id: id.to_string(), + } + .into(), + ), + } + } + + fn removed_event(id: &str) -> v1::ScheduleEvent { + v1::ScheduleEvent { + event: Some( + v1::ScheduleRemoved { + schedule_id: id.to_string(), + } + .into(), + ), + } + } + + #[test] + fn event_projection_replays_latest_state() { + let events = [added_event("backup"), paused_event("backup"), removed_event("backup")]; + let mut state = initial_state(); + + for event in &events { + state = apply("backup", state, event).unwrap(); + } + + assert_eq!(state, ScheduleStreamState::Deleted("backup".to_string())); + } + + #[test] + fn event_projection_preserves_rrule_schedule_fields() { + let state = apply("backup", initial_state(), &rrule_added_event("backup")).unwrap(); + let ScheduleStreamState::Present(job) = state else { + panic!("expected projected job"); + }; + + let dtstart: DateTime = "2026-05-24T09:00:00+00:00".parse().unwrap(); + let rdate: DateTime = "2026-05-26T09:00:00+00:00".parse().unwrap(); + let exdate: DateTime = "2026-06-01T09:00:00+00:00".parse().unwrap(); + + assert_eq!( + job.schedule, + ScheduleEventSchedule::RRule { + dtstart, + rrule: "FREQ=WEEKLY;BYDAY=MO".to_string(), + timezone: Some("UTC".to_string()), + rdate: vec![rdate], + exdate: vec![exdate], + } + ); + } + + #[test] + fn event_projection_rejects_recreating_deleted_job() { + let error = apply( + "backup", + ScheduleStreamState::Deleted("backup".to_string()), + &added_event("backup"), + ) + .unwrap_err(); + + assert!(matches!( + error, + ScheduleTransitionError::CannotAddDeletedSchedule { .. } + )); + } + + #[test] + fn state_change_requires_existing_job() { + let error = apply("missing", initial_state(), &paused_event("missing")).unwrap_err(); + + assert!(matches!( + error, + ScheduleTransitionError::MissingScheduleForStateChange { .. } + )); + } + + #[test] + fn projection_change_tracks_latest_state() { + let before = initial_state(); + let after = apply("backup", before.clone(), &added_event("backup")).unwrap(); + assert_eq!( + projection_change(&before, &after), + Some(ProjectionChange::Upsert(expected_schedule("backup"))) + ); + + let updated = apply("backup", after.clone(), &paused_event("backup")).unwrap(); + match projection_change(&after, &updated).unwrap() { + ProjectionChange::Upsert(job) => assert_eq!(job.status, ScheduleEventStatus::Paused), + ProjectionChange::Delete(_) => panic!("expected upsert change"), + } + } + + #[test] + fn initial_state_rejects_adding_existing_job() { + let error = apply( + "backup", + ScheduleStreamState::Present(expected_schedule("backup")), + &added_event("backup"), + ) + .unwrap_err(); + assert!(matches!( + error, + ScheduleTransitionError::CannotAddExistingSchedule { .. } + )); + } + + #[test] + fn watched_projection_change_does_not_mutate_state_before_commit() { + let mut state = BTreeMap::new(); + let prepared = prepare_projection_change(&state, "backup", &added_event("backup")).unwrap(); + + assert!(state.is_empty()); + assert_eq!( + prepared.change, + Some(ProjectionChange::Upsert(expected_schedule("backup"))) + ); + + commit_watched_projection_state(&mut state, prepared.stream_id, prepared.next_state); + + assert!(matches!(state.get("backup"), Some(ScheduleStreamState::Present(_)))); + } + + #[test] + fn watched_projection_commits_tombstone_even_without_public_change() { + let mut state = BTreeMap::new(); + let prepared = prepare_projection_change(&state, "backup", &removed_event("backup")).unwrap(); + + assert!(prepared.change.is_none()); + + commit_watched_projection_state(&mut state, prepared.stream_id, prepared.next_state); + + assert_eq!( + state.get("backup"), + Some(&ScheduleStreamState::Deleted("backup".to_string())) + ); + } + + #[test] + fn initial_removal_creates_deleted_tombstone() { + let state = apply("backup", initial_state(), &removed_event("backup")).unwrap(); + assert_eq!(state, ScheduleStreamState::Deleted("backup".to_string())); + } + + #[test] + fn watch_start_sequence_moves_past_bootstrap_tail() { + assert_eq!(next_watch_start_sequence(0), 1); + assert_eq!(next_watch_start_sequence(41), 42); + } + + #[test] + fn watch_consumer_replays_only_after_bootstrap_boundary() { + let config = event_watch_consumer_config(42); + + assert_eq!( + config.deliver_policy, + DeliverPolicy::ByStartSequence { start_sequence: 42 } + ); + assert_eq!(config.ack_policy, AckPolicy::Explicit); + assert_eq!(config.replay_policy, ReplayPolicy::Instant); + } + + #[test] + fn read_model_state_rejects_recreating_deleted_job() { + let mut states = BTreeMap::new(); + let stream_id = "alpha".to_string(); + + apply_event_to_read_model_state(&mut states, &stream_id, &added_event("alpha")).unwrap(); + apply_event_to_read_model_state(&mut states, &stream_id, &paused_event("alpha")).unwrap(); + apply_event_to_read_model_state(&mut states, &stream_id, &removed_event("alpha")).unwrap(); + let error = apply_event_to_read_model_state(&mut states, &stream_id, &added_event("alpha")).unwrap_err(); + + assert!(error.to_string().contains("deleted")); + assert_eq!(states.get("alpha"), Some(&ScheduleStreamState::Deleted(stream_id))); + } + + #[test] + fn read_model_state_rejects_invalid_transition_sequence() { + let stream_id = "alpha".to_string(); + let error = + apply_event_to_read_model_state(&mut BTreeMap::new(), &stream_id, &paused_event("alpha")).unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to apply schedule event to schedules read model") + ); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/mod.rs b/rsworkspace/crates/trogon-scheduler/src/queries/mod.rs new file mode 100644 index 000000000..7978dbcba --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/queries/mod.rs @@ -0,0 +1,7 @@ +mod get; +mod list; +mod schedule_id; + +pub use get::{GetSchedule, GetSchedule as GetScheduleCommand, run as get_schedule}; +pub use list::{ListSchedules, ListSchedules as ListSchedulesCommand, run as list_schedules}; +pub use schedule_id::{ScheduleId, ScheduleIdError}; From 7682f9020c6aeeeea7cdfa76cc12774e1a0216fb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 18 Jun 2026 01:39:13 -0400 Subject: [PATCH 02/11] test(scheduler): cover schedule store query paths Signed-off-by: Yordis Prieto --- .../trogon-scheduler/tests/integration.rs | 200 ++++++++++++++++++ .../trogon-scheduler/tests/schedule_unit.rs | 145 +++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 rsworkspace/crates/trogon-scheduler/tests/integration.rs create mode 100644 rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs diff --git a/rsworkspace/crates/trogon-scheduler/tests/integration.rs b/rsworkspace/crates/trogon-scheduler/tests/integration.rs new file mode 100644 index 000000000..378ba1615 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/tests/integration.rs @@ -0,0 +1,200 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::time::Duration; + +use async_nats::Request; +use async_nats::jetstream; +use trogon_decider_runtime::{CommandExecution, ReadFrom, ReadStreamRequest, StreamRead, TokioSnapshotTaskScheduler}; +use trogon_nats::{NatsConfig, connect as nats_connect}; +use trogon_scheduler::{ + CreateSchedule, GetScheduleCommand, PauseSchedule, RemoveSchedule, ResumeSchedule, ScheduleEventCase, + ScheduleEventSchedule, ScheduleEventStatus, ScheduleId, commands::domain as command_domain, connect_store, + get_schedule, state_v1, v1, +}; + +fn test_url() -> String { + std::env::var("NATS_TEST_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()) +} + +fn command_schedule_id(id: &str) -> command_domain::ScheduleId { + command_domain::ScheduleId::parse(id).unwrap() +} + +async fn connect() -> async_nats::Client { + let config = NatsConfig::from_url(test_url()); + nats_connect(&config, Duration::from_secs(10)) + .await + .expect("failed to connect to NATS") +} + +async fn connect_js() -> (async_nats::Client, jetstream::Context) { + let nats = connect().await; + let js = jetstream::new(nats.clone()); + (nats, js) +} + +async fn reset_state(js: &jetstream::Context) { + let _ = js.delete_stream(trogon_scheduler::kv::EVENTS_STREAM).await; + if let Ok(kv) = js.get_key_value(trogon_scheduler::kv::SCHEDULES_BUCKET).await { + let mut keys = kv.keys().await.unwrap(); + while let Some(result) = futures::StreamExt::next(&mut keys).await { + let key = result.unwrap(); + let _ = kv.purge(key).await; + } + } + if let Ok(kv) = js.get_key_value(trogon_scheduler::kv::COMMAND_SNAPSHOT_BUCKET).await { + let mut keys = kv.keys().await.unwrap(); + while let Some(result) = futures::StreamExt::next(&mut keys).await { + let key = result.unwrap(); + let _ = kv.purge(key).await; + } + } +} + +fn base_schedule(id: &str) -> CreateSchedule { + CreateSchedule { + id: command_schedule_id(id), + status: command_domain::ScheduleEventStatus::Scheduled, + schedule: command_domain::Schedule::every(Duration::from_secs(2)).unwrap(), + delivery: command_domain::Delivery::NatsEvent { + route: command_domain::DeliveryRoute::new("agent.run").unwrap(), + ttl: Some(command_domain::TtlDuration::from_secs(30).unwrap()), + source: None, + }, + message: command_domain::ScheduleMessage { + content: command_domain::MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + headers: command_domain::ScheduleHeaders::default(), + }, + } +} + +#[tokio::test] +#[ignore = "requires nightly NATS scheduler"] +async fn raw_js_info_request_with_explicit_inbox_works() { + let nats = connect().await; + let inbox = nats.new_inbox(); + let response = nats + .send_request( + "$JS.API.INFO", + Request::new() + .inbox(inbox) + .timeout(Some(Duration::from_secs(10))) + .payload(br#"{}"#.as_slice().into()), + ) + .await + .unwrap(); + + let body = String::from_utf8(response.payload.to_vec()).unwrap(); + assert!(body.contains("\"memory\"")); +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn event_store_rebuilds_current_state_for_new_client() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + + let store = connect_store(nats.clone()).await.unwrap(); + let mut job = base_schedule("eventful"); + job.schedule = command_domain::Schedule::cron("*/5 * * * * *", Some("UTC".to_string())).unwrap(); + let expected_schedule = ScheduleEventSchedule::Cron { + expr: "*/5 * * * * *".to_string(), + timezone: Some("UTC".to_string()), + }; + + CommandExecution::new(&store.event_store, &job).execute().await.unwrap(); + CommandExecution::new(&store.event_store, &PauseSchedule::new(command_schedule_id("eventful"))) + .with_snapshot(&store.event_store) + .with_task_runtime(TokioSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + + let fresh = connect_store(nats).await.unwrap(); + let rebuilt = get_schedule( + &fresh.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse("eventful").unwrap()), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(rebuilt.status, ScheduleEventStatus::Paused); + assert_eq!(rebuilt.schedule, expected_schedule); +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn commands_execute_full_lifecycle_against_event_store() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + + let job = base_schedule("lifecycle"); + let command_id = command_schedule_id("lifecycle"); + + let added = CommandExecution::new(&store.event_store, &job).execute().await.unwrap(); + let added_position = added.stream_position; + assert_eq!( + added.state.state.as_ref().and_then(|value| value.as_known()), + Some(state_v1::StateValue::STATE_VALUE_PRESENT_ENABLED) + ); + + let paused = CommandExecution::new(&store.event_store, &PauseSchedule::new(command_id.clone())) + .with_snapshot(&store.event_store) + .with_task_runtime(TokioSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + assert_eq!(paused.stream_position.as_u64(), added_position.as_u64() + 1); + assert_eq!( + paused.state.state.as_ref().and_then(|value| value.as_known()), + Some(state_v1::StateValue::STATE_VALUE_PRESENT_DISABLED) + ); + + let resumed = CommandExecution::new(&store.event_store, &ResumeSchedule::new(command_id.clone())) + .with_snapshot(&store.event_store) + .with_task_runtime(TokioSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + assert_eq!(resumed.stream_position.as_u64(), paused.stream_position.as_u64() + 1); + assert_eq!( + resumed.state.state.as_ref().and_then(|value| value.as_known()), + Some(state_v1::StateValue::STATE_VALUE_PRESENT_ENABLED) + ); + + let removed = CommandExecution::new(&store.event_store, &RemoveSchedule::new(command_id)) + .with_snapshot(&store.event_store) + .with_task_runtime(TokioSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + assert_eq!(removed.stream_position.as_u64(), resumed.stream_position.as_u64() + 1); + assert_eq!( + removed.state.state.as_ref().and_then(|value| value.as_known()), + Some(state_v1::StateValue::STATE_VALUE_DELETED) + ); + + let fresh = connect_store(nats).await.unwrap(); + let stream = fresh + .event_store + .read_stream(ReadStreamRequest { + stream_id: "lifecycle", + from: ReadFrom::Beginning, + }) + .await + .unwrap(); + assert_eq!(stream.current_position, Some(removed.stream_position)); + assert_eq!(stream.events.len(), 4); + + let events = stream + .events + .iter() + .map(|event| event.decode::().unwrap().into_decoded().unwrap()) + .collect::>(); + assert!(matches!(&events[0].event, Some(ScheduleEventCase::ScheduleCreated(_)))); + assert!(matches!(&events[1].event, Some(ScheduleEventCase::SchedulePaused(_)))); + assert!(matches!(&events[2].event, Some(ScheduleEventCase::ScheduleResumed(_)))); + assert!(matches!(&events[3].event, Some(ScheduleEventCase::ScheduleRemoved(_)))); +} diff --git a/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs b/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs new file mode 100644 index 000000000..1709aa12c --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs @@ -0,0 +1,145 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use trogon_decider_runtime::{CommandExecution, ImmediateSnapshotTaskScheduler, StreamPosition}; +use trogon_scheduler::{ + CreateSchedule, GetScheduleCommand, ListSchedulesCommand, MessageContent, MessageEnvelope, MessageHeaders, + PauseSchedule, RemoveSchedule, Schedule, ScheduleEventDelivery, ScheduleEventSchedule, ScheduleEventStatus, + ScheduleId, ScheduleWriteCondition, commands::domain as command_domain, mocks::MockSchedulerStore, +}; + +fn position(value: u64) -> StreamPosition { + StreamPosition::try_new(value).expect("test stream position must be non-zero") +} + +fn command_schedule_id(id: &str) -> command_domain::ScheduleId { + command_domain::ScheduleId::parse(id).unwrap() +} + +fn expected_schedule(id: &str) -> Schedule { + Schedule { + id: id.to_string(), + status: ScheduleEventStatus::Scheduled, + schedule: ScheduleEventSchedule::Every { every_sec: 30 }, + delivery: ScheduleEventDelivery::NatsMessage { + subject: "agent.run".to_string(), + ttl_sec: None, + source: None, + }, + message: MessageEnvelope { + content: MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + headers: MessageHeaders::default(), + }, + } +} + +fn command_base_schedule(id: &str) -> CreateSchedule { + CreateSchedule { + id: command_schedule_id(id), + status: command_domain::ScheduleEventStatus::Scheduled, + schedule: command_domain::Schedule::every(std::time::Duration::from_secs(30)).unwrap(), + delivery: command_domain::Delivery::nats_event("agent.run").unwrap(), + message: command_domain::ScheduleMessage { + content: command_domain::MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + headers: command_domain::ScheduleHeaders::default(), + }, + } +} + +#[tokio::test] +async fn client_register_then_get() { + let store = MockSchedulerStore::new(); + + let job = command_base_schedule("backup"); + CommandExecution::new(&store, &job).execute().await.unwrap(); + + let got = store + .get_schedule(GetScheduleCommand::new(ScheduleId::parse("backup").unwrap())) + .await + .unwrap(); + assert_eq!(got, Some(expected_schedule("backup"))); +} + +#[tokio::test] +async fn client_pause_job_toggles_job() { + let store = MockSchedulerStore::new(); + + CommandExecution::new(&store, &command_base_schedule("toggle")) + .execute() + .await + .unwrap(); + CommandExecution::new(&store, &PauseSchedule::new(command_schedule_id("toggle"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + + let got = store + .get_schedule(GetScheduleCommand::new(ScheduleId::parse("toggle").unwrap())) + .await + .unwrap() + .unwrap(); + assert_eq!(got.status, ScheduleEventStatus::Paused); +} + +#[tokio::test] +async fn client_remove_and_list_schedules_use_store_paths() { + let store = MockSchedulerStore::new(); + + CommandExecution::new(&store, &command_base_schedule("alpha")) + .execute() + .await + .unwrap(); + CommandExecution::new(&store, &command_base_schedule("beta")) + .execute() + .await + .unwrap(); + + let listed = store.list_schedules(ListSchedulesCommand).await.unwrap(); + assert_eq!(listed.len(), 2); + + CommandExecution::new(&store, &RemoveSchedule::new(command_schedule_id("beta"))) + .with_snapshot(&store) + .with_task_runtime(ImmediateSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + + assert!( + store + .get_schedule(GetScheduleCommand::new(ScheduleId::parse("beta").unwrap())) + .await + .unwrap() + .is_none() + ); + assert_eq!(store.list_schedules(ListSchedulesCommand).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn client_rejects_invalid_route() { + let error = command_domain::Delivery::nats_event("agent.>").unwrap_err(); + + assert!(error.to_string().contains("route")); +} + +#[tokio::test] +async fn client_rejects_invalid_source_subject() { + let error = command_domain::SamplingSource::latest_from_subject("sensors.>").unwrap_err(); + + assert!(error.to_string().contains("sampling subject")); +} + +#[tokio::test] +async fn client_rejects_stale_version() { + let error = ScheduleWriteCondition::MustBeAtPosition(position(99)) + .ensure( + "stale", + trogon_scheduler::config::ScheduleWriteState::new(Some(position(1)), true), + ) + .unwrap_err(); + + assert!(matches!( + error, + trogon_scheduler::SchedulerError::OptimisticConcurrencyConflict { .. } + )); +} From b971985bca1d6febb4ed73f42946227943a9386e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 18 Jun 2026 01:40:04 -0400 Subject: [PATCH 03/11] fix(scheduler): preserve command snapshot compatibility Signed-off-by: Yordis Prieto --- .../src/commands/create_schedule.rs | 13 ++++- .../crates/trogon-scheduler/src/mocks.rs | 47 +++++++++---------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs b/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs index 1143dd46d..e0e4c7750 100644 --- a/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs +++ b/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs @@ -1,5 +1,5 @@ use buffa::MessageField; -use trogon_decider_runtime::{Decider, Decision, WritePrecondition}; +use trogon_decider_runtime::{CommandSnapshotPolicy, Decider, Decision, FrequencySnapshot, WritePrecondition}; use trogonai_proto::convert::DurationConversionError; use trogonai_proto::scheduler::schedules::{state_v1, v1}; @@ -17,6 +17,12 @@ pub struct CreateSchedule { pub message: ScheduleMessage, } +impl CreateSchedule { + pub fn new(command: Self) -> Self { + command + } +} + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum CreateScheduleDecideError { #[error("schedule '{id}' already exists")] @@ -99,6 +105,11 @@ impl Decider for CreateSchedule { } } +impl CommandSnapshotPolicy for CreateSchedule { + type SnapshotPolicy = FrequencySnapshot; + const SNAPSHOT_POLICY: Self::SnapshotPolicy = super::snapshot::COMMAND_SNAPSHOT_POLICY; +} + #[cfg(test)] mod tests { use std::error::Error; diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index b1d26b00b..ee379670e 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -217,17 +217,20 @@ impl MockSchedulerStore { pub(crate) fn read_command_snapshot( &self, - stream_id: &(impl AsRef + ?Sized), + snapshot_id: &(impl AsRef + ?Sized), ) -> Result>, SchedulerError> where Payload: SnapshotPayloadDecode + SnapshotType, - Payload::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, { + let snapshot_type = Payload::snapshot_type() + .map_err(|source| SchedulerError::event_source("failed to resolve command snapshot type", source))?; self.command_snapshots .lock() .unwrap() - .get(Payload::SNAPSHOT_STREAM_PREFIX) - .and_then(|snapshots| snapshots.get(stream_id.as_ref()).cloned()) + .get(snapshot_type.as_ref()) + .and_then(|snapshots| snapshots.get(snapshot_id.as_ref()).cloned()) .map(|snapshot| { Payload::decode(SnapshotPayloadData::new(snapshot.payload.as_slice())) .map(|payload| Snapshot::new(snapshot.position, payload)) @@ -635,7 +638,8 @@ impl StreamAppend for MockSchedulerStore { impl SnapshotRead for MockSchedulerStore where Payload: SnapshotPayloadDecode + SnapshotType + Send, - Payload::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, { type Error = SchedulerError; @@ -643,7 +647,7 @@ where &self, request: ReadSnapshotRequest<'_, str>, ) -> Result, Self::Error> { - self.read_command_snapshot(request.stream_id) + self.read_command_snapshot(request.snapshot_id) .map(|snapshot| ReadSnapshotResponse { snapshot }) } } @@ -651,7 +655,8 @@ where impl SnapshotWrite for MockSchedulerStore where Payload: SnapshotPayloadEncode + SnapshotType + Send, - Payload::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, { type Error = SchedulerError; @@ -666,12 +671,14 @@ where SchedulerError::event_source("failed to encode command snapshot payload", source) })?, }; + let snapshot_type = Payload::snapshot_type() + .map_err(|source| SchedulerError::event_source("failed to resolve command snapshot type", source))?; self.command_snapshots .lock() .unwrap() - .entry(Payload::SNAPSHOT_STREAM_PREFIX.to_string()) + .entry(snapshot_type.to_string()) .or_default() - .insert(request.stream_id.to_string(), snapshot); + .insert(request.snapshot_id.to_string(), snapshot); Ok(WriteSnapshotResponse) } } @@ -714,11 +721,11 @@ mod tests { } } - fn command_base_schedule(id: &str) -> command_domain::Schedule { - command_domain::Schedule { + fn command_base_schedule(id: &str) -> CreateSchedule { + CreateSchedule { id: command_schedule_id(id), - status: command_domain::ScheduleStatus::Enabled, - schedule: command_domain::ScheduleSpec::every(30).unwrap(), + status: command_domain::ScheduleEventStatus::Scheduled, + schedule: command_domain::Schedule::every(std::time::Duration::from_secs(30)).unwrap(), delivery: command_domain::Delivery::nats_event("agent.run").unwrap(), message: command_domain::ScheduleMessage { content: command_domain::MessageContent::from_static(r#"{"kind":"heartbeat"}"#), @@ -864,18 +871,8 @@ mod tests { #[tokio::test] async fn mock_scheduler_store_rejects_invalid_specs_and_state_errors() { let store = MockSchedulerStore::new(); - let invalid_error = serde_json::from_value::(serde_json::json!({ - "id": "bad", - "schedule": { "type": "every", "every_sec": 30 }, - "delivery": { - "type": "nats_event", - "route": "agent.run", - "source": { "type": "latest_from_subject", "subject": "sensors.>" } - }, - "content": "{\"kind\":\"heartbeat\"}" - })) - .unwrap_err(); - assert!(invalid_error.to_string().contains("sampling source")); + let invalid_error = command_domain::SamplingSource::latest_from_subject("sensors.>").unwrap_err(); + assert!(invalid_error.to_string().contains("sampling subject")); CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) .with_snapshot(&store) From 61ecf9be40615b04607616a9f429c357fe408b9b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 10 Jun 2026 02:16:39 -0400 Subject: [PATCH 04/11] perf(decider-runtime): avoid unnecessary command state reads Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-scheduler/src/mocks.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index ee379670e..973c3d067 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -864,7 +864,11 @@ mod tests { .unwrap_err(); assert!(matches!( deleted_error, - CommandError::Decide(crate::CreateScheduleError::ScheduleDeleted { .. }) + CommandError::Append(SchedulerError::OptimisticConcurrencyConflict { + expected: StreamWritePrecondition::NoStream, + current_position: Some(_), + .. + }) )); } From dad026bb66228d6324ee76afed4e842908b00327 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 11 Jun 2026 16:21:52 -0400 Subject: [PATCH 05/11] fix(scheduler): avoid snapshot coupling for create Signed-off-by: Yordis Prieto --- .../src/commands/create_schedule.rs | 13 +------------ rsworkspace/crates/trogon-scheduler/src/mocks.rs | 12 +++--------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs b/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs index e0e4c7750..1143dd46d 100644 --- a/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs +++ b/rsworkspace/crates/trogon-scheduler/src/commands/create_schedule.rs @@ -1,5 +1,5 @@ use buffa::MessageField; -use trogon_decider_runtime::{CommandSnapshotPolicy, Decider, Decision, FrequencySnapshot, WritePrecondition}; +use trogon_decider_runtime::{Decider, Decision, WritePrecondition}; use trogonai_proto::convert::DurationConversionError; use trogonai_proto::scheduler::schedules::{state_v1, v1}; @@ -17,12 +17,6 @@ pub struct CreateSchedule { pub message: ScheduleMessage, } -impl CreateSchedule { - pub fn new(command: Self) -> Self { - command - } -} - #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum CreateScheduleDecideError { #[error("schedule '{id}' already exists")] @@ -105,11 +99,6 @@ impl Decider for CreateSchedule { } } -impl CommandSnapshotPolicy for CreateSchedule { - type SnapshotPolicy = FrequencySnapshot; - const SNAPSHOT_POLICY: Self::SnapshotPolicy = super::snapshot::COMMAND_SNAPSHOT_POLICY; -} - #[cfg(test)] mod tests { use std::error::Error; diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index 973c3d067..abe943456 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -802,9 +802,7 @@ mod tests { .unwrap(); assert_eq!(seeded, expected_schedule("seeded")); - CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) - .with_snapshot(&store) - .with_task_runtime(ImmediateSnapshotTaskScheduler) + CommandExecution::new(&store, &command_base_schedule("alpha")) .execute() .await .unwrap(); @@ -856,9 +854,7 @@ mod tests { .is_none() ); - let deleted_error = CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) - .with_snapshot(&store) - .with_task_runtime(ImmediateSnapshotTaskScheduler) + let deleted_error = CommandExecution::new(&store, &command_base_schedule("alpha")) .execute() .await .unwrap_err(); @@ -878,9 +874,7 @@ mod tests { let invalid_error = command_domain::SamplingSource::latest_from_subject("sensors.>").unwrap_err(); assert!(invalid_error.to_string().contains("sampling subject")); - CommandExecution::new(&store, &CreateSchedule::new(command_base_schedule("alpha"))) - .with_snapshot(&store) - .with_task_runtime(ImmediateSnapshotTaskScheduler) + CommandExecution::new(&store, &command_base_schedule("alpha")) .execute() .await .unwrap(); From 9bcd88c6cc5c5780fa16fd654ec48843b6f4a51a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 11 Jun 2026 19:01:48 -0400 Subject: [PATCH 06/11] Remove unused schedule resolution and publisher code Drop the `ResolvedSchedule`, `SchedulePublisher`, `LeaderLock` traits, and their associated KV constants and tests. These were carryovers from an earlier design where the scheduler directly managed NATS schedule streams and leader election; the current architecture handles delivery through the events stream alone. Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-scheduler/src/kv.rs | 226 ++++++++++++++++++ .../crates/trogon-scheduler/src/mocks.rs | 174 +------------- .../crates/trogon-scheduler/src/nats.rs | 85 +++++++ 3 files changed, 314 insertions(+), 171 deletions(-) create mode 100644 rsworkspace/crates/trogon-scheduler/src/kv.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/nats.rs diff --git a/rsworkspace/crates/trogon-scheduler/src/kv.rs b/rsworkspace/crates/trogon-scheduler/src/kv.rs new file mode 100644 index 000000000..eab2e9a32 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/kv.rs @@ -0,0 +1,226 @@ +#![cfg_attr(coverage, allow(dead_code, unused_imports))] + +use async_nats::jetstream::{self, kv, stream}; +use trogon_nats::jetstream::{ + JetStreamGetKeyValue, JetStreamGetStream, is_create_key_value_already_exists, is_create_stream_already_exists, +}; + +use crate::error::SchedulerError; + +pub const SCHEDULES_BUCKET: &str = "scheduler_schedules"; +pub const EVENTS_STREAM: &str = "SCHEDULER_EVENTS"; +pub const EVENTS_SUBJECT_PREFIX: &str = "scheduler.schedules.events."; +pub const EVENTS_SUBJECT_PATTERN: &str = "scheduler.schedules.events.>"; +pub const COMMAND_SNAPSHOT_BUCKET: &str = "scheduler_command_snapshots"; +pub const SCHEDULES_CHECKPOINT_KEY: &str = "_query.schedules.last_event_sequence"; + +#[cfg(not(coverage))] +pub async fn get_or_create_schedules_bucket(js: &jetstream::Context) -> Result { + get_or_create( + js, + kv::Config { + bucket: SCHEDULES_BUCKET.to_string(), + history: 5, + ..Default::default() + }, + ) + .await +} + +#[cfg(coverage)] +pub async fn get_or_create_schedules_bucket(_js: &jetstream::Context) -> Result { + Err(SchedulerError::kv_source( + "coverage stub does not provision schedules buckets", + std::io::Error::other(SCHEDULES_BUCKET), + )) +} + +#[cfg(not(coverage))] +pub async fn get_or_create_command_snapshot_bucket(js: &jetstream::Context) -> Result { + get_or_create( + js, + kv::Config { + bucket: COMMAND_SNAPSHOT_BUCKET.to_string(), + history: 1, + ..Default::default() + }, + ) + .await +} + +#[cfg(coverage)] +pub async fn get_or_create_command_snapshot_bucket(_js: &jetstream::Context) -> Result { + Err(SchedulerError::kv_source( + "coverage stub does not provision command snapshot buckets", + std::io::Error::other(COMMAND_SNAPSHOT_BUCKET), + )) +} + +#[cfg(not(coverage))] +pub async fn get_or_create(js: &jetstream::Context, config: kv::Config) -> Result { + let name = config.bucket.clone(); + match js.create_key_value(config).await { + Ok(store) => Ok(store), + Err(source) if is_create_key_value_already_exists(&source) => js.get_key_value(&name).await.map_err(|source| { + SchedulerError::kv_source( + "failed to get existing key-value bucket after create reported already exists", + source, + ) + }), + Err(source) => Err(SchedulerError::kv_source("failed to create key-value bucket", source)), + } +} + +#[cfg(coverage)] +pub async fn get_or_create(_js: &jetstream::Context, _config: kv::Config) -> Result { + Err(SchedulerError::kv_source( + "coverage stub does not provision key-value buckets", + std::io::Error::other("coverage"), + )) +} + +#[cfg(not(coverage))] +pub async fn get_or_create_events_stream(js: &jetstream::Context) -> Result { + let config = stream::Config { + name: EVENTS_STREAM.to_string(), + subjects: vec![EVENTS_SUBJECT_PATTERN.to_string()], + allow_atomic_publish: true, + ..Default::default() + }; + + let stream = match js.create_stream(config.clone()).await { + Ok(stream) => stream, + Err(source) if is_create_stream_already_exists(&source) => { + js.get_stream(EVENTS_STREAM).await.map_err(|source| { + SchedulerError::event_source( + "failed to get existing events stream after create reported already exists", + source, + ) + })? + } + Err(source) => { + return Err(SchedulerError::event_source( + "failed to get or create events stream", + source, + )); + } + }; + + ensure_events_stream_config(js, stream, config).await +} + +#[cfg(coverage)] +pub async fn get_or_create_events_stream(_js: &jetstream::Context) -> Result { + Err(SchedulerError::event_source( + "coverage stub does not provision events streams", + std::io::Error::other(EVENTS_STREAM), + )) +} + +#[cfg(not(coverage))] +async fn ensure_events_stream_config( + js: &jetstream::Context, + stream: stream::Stream, + desired: stream::Config, +) -> Result { + let mut current = stream.cached_info().config.clone(); + if current.allow_atomic_publish == desired.allow_atomic_publish && current.subjects == desired.subjects { + return Ok(stream); + } + + current.allow_atomic_publish = desired.allow_atomic_publish; + current.subjects = desired.subjects; + js.update_stream(current) + .await + .map_err(|source| SchedulerError::event_source("failed to update events stream configuration", source))?; + js.get_stream(EVENTS_STREAM) + .await + .map_err(|source| SchedulerError::event_source("failed to reopen updated events stream", source)) +} + +#[cfg(not(coverage))] +pub async fn open_command_snapshot_bucket(js: &J) -> Result +where + J: JetStreamGetKeyValue, +{ + js.get_key_value(COMMAND_SNAPSHOT_BUCKET) + .await + .map_err(|source| SchedulerError::kv_source("failed to open command snapshot bucket", source)) +} + +#[cfg(coverage)] +pub async fn open_command_snapshot_bucket(_js: &J) -> Result +where + J: JetStreamGetKeyValue, +{ + Err(SchedulerError::kv_source( + "coverage stub does not open the command snapshot bucket", + std::io::Error::other(COMMAND_SNAPSHOT_BUCKET), + )) +} + +#[cfg(not(coverage))] +pub(crate) async fn open_schedules_bucket(js: &J) -> Result +where + J: JetStreamGetKeyValue, +{ + js.get_key_value(SCHEDULES_BUCKET) + .await + .map_err(|source| SchedulerError::kv_source("failed to open schedules bucket", source)) +} + +#[cfg(coverage)] +pub(crate) async fn open_schedules_bucket(_js: &J) -> Result +where + J: JetStreamGetKeyValue, +{ + Err(SchedulerError::kv_source( + "coverage stub does not open the schedules bucket", + std::io::Error::other(SCHEDULES_BUCKET), + )) +} + +#[cfg(not(coverage))] +pub(crate) async fn open_events_stream(js: &J) -> Result +where + J: JetStreamGetStream, +{ + js.get_stream(EVENTS_STREAM) + .await + .map_err(|source| SchedulerError::event_source("failed to open events stream", source)) +} + +#[cfg(coverage)] +pub(crate) async fn open_events_stream(_js: &J) -> Result +where + J: JetStreamGetStream, +{ + Err(SchedulerError::event_source( + "coverage stub does not open the events stream", + std::io::Error::other(EVENTS_STREAM), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_nats::jetstream::context::{ + CreateKeyValueError, CreateKeyValueErrorKind, CreateStreamError, CreateStreamErrorKind, + }; + + fn stream_exists_error() -> CreateStreamError { + let source: jetstream::Error = serde_json::from_str( + r#"{"code":400,"err_code":10058,"description":"stream name already in use with a different configuration"}"#, + ) + .unwrap(); + + CreateStreamError::new(CreateStreamErrorKind::JetStream(source)) + } + + #[test] + fn create_key_value_already_exists_matches_wrapped_stream_exists_error() { + let error = CreateKeyValueError::with_source(CreateKeyValueErrorKind::BucketCreate, stream_exists_error()); + + assert!(is_create_key_value_already_exists(&error)); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index abe943456..8096b027f 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -1,13 +1,8 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::num::NonZeroU64; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, -}; +use std::sync::{Arc, Mutex}; -use async_nats::jetstream::kv; use buffa::MessageField; -use bytes::Bytes; use chrono::{DateTime, Utc}; use trogon_decider_runtime::snapshot::Snapshot; use trogon_decider_runtime::{ @@ -17,12 +12,10 @@ use trogon_decider_runtime::{ StreamAppend, StreamEvent, StreamPosition, StreamRead, StreamWritePrecondition, WriteSnapshotRequest, WriteSnapshotResponse, }; -use trogon_nats::lease::{ReleaseLease, RenewLease, TryAcquireLease}; use trogon_std::{NowV7, UuidV7Generator}; use crate::{ - DeliveryKind, GetSchedule, ListSchedules, ResolvedSchedule, ScheduleEventCase, ScheduleKind, ScheduleStatusKind, - SourceKind, + DeliveryKind, GetSchedule, ListSchedules, ScheduleEventCase, ScheduleKind, ScheduleStatusKind, SourceKind, config::{ScheduleWriteCondition, ScheduleWriteState}, error::SchedulerError, projections::{LoadAndWatchSchedulesResult, ScheduleWatchStream}, @@ -30,118 +23,9 @@ use crate::{ MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSamplingSource, ScheduleEventSchedule, ScheduleEventStatus, }, - traits::SchedulePublisher, v1, }; -#[derive(Clone, Default)] -pub struct MockSchedulePublisher { - upserts: Arc>>, - removals: Arc>>, - active: Arc>>, -} - -impl MockSchedulePublisher { - pub fn new() -> Self { - Self::default() - } - - pub fn upserts(&self) -> Vec { - self.upserts.lock().unwrap().clone() - } - - pub fn removals(&self) -> Vec { - self.removals.lock().unwrap().clone() - } - - pub fn seed_active_schedule(&self, job_id: &str) { - self.active.lock().unwrap().insert(job_id.to_string()); - } -} - -impl SchedulePublisher for MockSchedulePublisher { - type Error = SchedulerError; - - async fn active_schedule_ids(&self) -> Result, Self::Error> { - Ok(self.active.lock().unwrap().clone()) - } - - async fn upsert_schedule(&self, job: &ResolvedSchedule) -> Result<(), Self::Error> { - self.upserts.lock().unwrap().push(job.schedule_subject().to_string()); - self.active.lock().unwrap().insert(job.id().to_string()); - Ok(()) - } - - async fn remove_schedule(&self, job_id: &str) -> Result<(), Self::Error> { - self.removals.lock().unwrap().push(job_id.to_string()); - self.active.lock().unwrap().remove(job_id); - Ok(()) - } -} - -#[derive(Clone)] -pub struct MockLeaderLock { - allow_acquire: Arc, - allow_renew: Arc, - next_revision: Arc, -} - -impl Default for MockLeaderLock { - fn default() -> Self { - Self { - allow_acquire: Arc::new(AtomicBool::new(true)), - allow_renew: Arc::new(AtomicBool::new(true)), - next_revision: Arc::new(AtomicU64::new(1)), - } - } -} - -impl MockLeaderLock { - pub fn new() -> Self { - Self::default() - } - - pub fn set_allow_acquire(&self, allowed: bool) { - self.allow_acquire.store(allowed, Ordering::SeqCst); - } - - pub fn set_allow_renew(&self, allowed: bool) { - self.allow_renew.store(allowed, Ordering::SeqCst); - } -} - -impl TryAcquireLease for MockLeaderLock { - type Error = kv::CreateError; - - async fn try_acquire(&self, _value: Bytes) -> Result { - if self.allow_acquire.load(Ordering::SeqCst) { - Ok(self.next_revision.fetch_add(1, Ordering::SeqCst)) - } else { - Err(kv::CreateError::new(kv::CreateErrorKind::AlreadyExists)) - } - } -} - -impl RenewLease for MockLeaderLock { - type Error = kv::UpdateError; - - async fn renew(&self, _value: Bytes, revision: u64) -> Result { - if self.allow_renew.load(Ordering::SeqCst) { - Ok(revision + 1) - } else { - Err(kv::UpdateError::new(kv::UpdateErrorKind::Other)) - } - } -} - -impl ReleaseLease for MockLeaderLock { - type Error = kv::DeleteError; - - async fn release(&self, _revision: u64) -> Result<(), Self::Error> { - Ok(()) - } -} - #[derive(Clone, Default)] pub struct MockSchedulerStore { schedules: Arc>>, @@ -738,58 +622,6 @@ mod tests { base_schedule(id) } - #[tokio::test] - async fn mock_schedule_publisher_tracks_active_schedules() { - let publisher = MockSchedulePublisher::new(); - publisher.seed_active_schedule("orphan"); - let schedule = expected_schedule("alpha"); - let details = v1::ScheduleCreated { - schedule_id: schedule.id.clone(), - status: MessageField::some(v1::ScheduleStatus { - kind: Some(match schedule.status { - ScheduleEventStatus::Scheduled => v1::schedule_status::Scheduled {}.into(), - ScheduleEventStatus::Paused => v1::schedule_status::Paused {}.into(), - }), - }), - schedule: MessageField::some(super::proto_schedule(&schedule.schedule)), - delivery: MessageField::some(super::proto_delivery(&schedule.delivery)), - message: MessageField::some(super::proto_message(&schedule.message)), - }; - let resolved = ResolvedSchedule::from_event("alpha", &details).unwrap(); - - let active = publisher.active_schedule_ids().await.unwrap(); - assert!(active.contains("orphan")); - - publisher.upsert_schedule(&resolved).await.unwrap(); - publisher.remove_schedule("orphan").await.unwrap(); - - assert_eq!(publisher.upserts(), vec!["scheduler.schedules.alpha"]); - assert_eq!(publisher.removals(), vec!["orphan"]); - assert!(publisher.active_schedule_ids().await.unwrap().contains("alpha")); - } - - #[tokio::test] - async fn mock_leader_lock_covers_success_and_failure_paths() { - let lock = MockLeaderLock::new(); - - let first = lock.try_acquire(Bytes::new()).await.unwrap(); - assert_eq!(first, 1); - assert_eq!(lock.renew(Bytes::new(), first).await.unwrap(), 2); - lock.release(first).await.unwrap(); - - lock.allow_acquire.store(false, Ordering::SeqCst); - assert_eq!( - lock.try_acquire(Bytes::new()).await.unwrap_err().kind(), - kv::CreateErrorKind::AlreadyExists - ); - - lock.allow_renew.store(false, Ordering::SeqCst); - assert_eq!( - lock.renew(Bytes::new(), 2).await.unwrap_err().kind(), - kv::UpdateErrorKind::Other - ); - } - #[tokio::test] async fn mock_scheduler_store_covers_crud_and_read_model_watch() { let store = MockSchedulerStore::new(); diff --git a/rsworkspace/crates/trogon-scheduler/src/nats.rs b/rsworkspace/crates/trogon-scheduler/src/nats.rs new file mode 100644 index 000000000..40d766104 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/nats.rs @@ -0,0 +1,85 @@ +#![cfg_attr(coverage, allow(dead_code))] + +use crate::{ + config::ScheduleWriteState, + error::SchedulerError, + kv::{EVENTS_STREAM, EVENTS_SUBJECT_PATTERN, EVENTS_SUBJECT_PREFIX}, +}; +use async_nats::jetstream::{self}; + +pub(crate) fn event_subject(job_id: &str) -> String { + format!("{EVENTS_SUBJECT_PREFIX}{job_id}") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct StreamSubjectState { + pub(crate) write_state: ScheduleWriteState, +} + +pub(crate) fn resolve_event_subject_state(canonical_state: Option) -> StreamSubjectState { + match canonical_state { + Some(write_state) => StreamSubjectState { write_state }, + None => StreamSubjectState { + write_state: ScheduleWriteState::new(None, false), + }, + } +} + +pub(crate) fn validate_events_stream(stream: &jetstream::stream::Stream) -> Result<(), SchedulerError> { + let config = &stream.cached_info().config; + if !config.allow_atomic_publish { + return Err(SchedulerError::event_source( + "events stream is missing allow_atomic", + std::io::Error::other(EVENTS_STREAM), + )); + } + if !config.subjects.iter().any(|subject| subject == EVENTS_SUBJECT_PATTERN) { + return Err(SchedulerError::event_source( + "events stream is missing canonical schedule event subject coverage", + std::io::Error::other(EVENTS_STREAM), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ScheduleWriteCondition; + use trogon_decider_runtime::StreamPosition; + + fn position(value: u64) -> StreamPosition { + StreamPosition::try_new(value).expect("test stream position must be non-zero") + } + + #[test] + fn write_condition_rejects_unexpected_position() { + let error = ScheduleWriteCondition::MustBeAtPosition(position(3)) + .ensure("alpha", ScheduleWriteState::new(Some(position(4)), true)) + .unwrap_err(); + + assert!(matches!( + error, + SchedulerError::OptimisticConcurrencyConflict { + current_position: Some(_), + .. + } + )); + } + + #[test] + fn new_streams_use_canonical_event_subject() { + let state = resolve_event_subject_state(None); + + assert_eq!(state.write_state.current_position(), None); + assert!(!state.write_state.exists()); + } + + #[test] + fn deleted_streams_keep_their_subject_and_still_count_as_existing() { + let state = resolve_event_subject_state(Some(ScheduleWriteState::new(Some(position(12)), true))); + + assert_eq!(state.write_state.current_position(), Some(position(12))); + assert!(state.write_state.exists()); + } +} From f4b7a185a15c2bbe112e85204f94252abfef1d99 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 18 Jun 2026 01:43:06 -0400 Subject: [PATCH 07/11] refactor(scheduler): align projections with execution worker Signed-off-by: Yordis Prieto --- .../crates/trogon-scheduler/src/mocks.rs | 18 +- .../trogon-scheduler/src/projections/mod.rs | 3 + .../src/projections/schedules.rs | 360 +----------------- 3 files changed, 15 insertions(+), 366 deletions(-) create mode 100644 rsworkspace/crates/trogon-scheduler/src/projections/mod.rs diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index 8096b027f..7aca5ee7c 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -18,7 +18,6 @@ use crate::{ DeliveryKind, GetSchedule, ListSchedules, ScheduleEventCase, ScheduleKind, ScheduleStatusKind, SourceKind, config::{ScheduleWriteCondition, ScheduleWriteState}, error::SchedulerError, - projections::{LoadAndWatchSchedulesResult, ScheduleWatchStream}, read_model::{ MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSamplingSource, ScheduleEventSchedule, ScheduleEventStatus, @@ -130,11 +129,6 @@ impl MockSchedulerStore { pub async fn list_schedules(&self, _command: ListSchedules) -> Result, SchedulerError> { Ok(self.schedules.lock().unwrap().values().cloned().collect()) } - - pub async fn load_and_watch_schedules(&self) -> LoadAndWatchSchedulesResult { - let jobs = self.schedules.lock().unwrap().values().cloned().collect(); - Ok((jobs, Box::pin(futures::stream::pending()) as ScheduleWatchStream)) - } } fn proto_schedule(schedule: &ScheduleEventSchedule) -> v1::Schedule { @@ -582,8 +576,6 @@ mod tests { fn position(value: u64) -> StreamPosition { StreamPosition::try_new(value).expect("test stream position must be non-zero") } - use futures::StreamExt; - fn command_schedule_id(id: &str) -> command_domain::ScheduleId { command_domain::ScheduleId::parse(id).unwrap() } @@ -623,7 +615,7 @@ mod tests { } #[tokio::test] - async fn mock_scheduler_store_covers_crud_and_read_model_watch() { + async fn mock_scheduler_store_covers_crud_and_read_model() { let store = MockSchedulerStore::new(); store.seed_schedule(base_schedule("seeded")); @@ -664,14 +656,6 @@ mod tests { let listed = store.list_schedules(ListSchedules).await.unwrap(); assert_eq!(listed.len(), 2); - let (watch_jobs, mut watcher) = store.load_and_watch_schedules().await.unwrap(); - assert_eq!(watch_jobs.len(), 2); - assert!( - tokio::time::timeout(std::time::Duration::from_millis(5), watcher.next()) - .await - .is_err() - ); - CommandExecution::new(&store, &RemoveSchedule::new(command_schedule_id("alpha"))) .with_snapshot(&store) .with_task_runtime(ImmediateSnapshotTaskScheduler) diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs new file mode 100644 index 000000000..e62a91c65 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs @@ -0,0 +1,3 @@ +mod schedules; + +pub(crate) use schedules::{catch_up_schedules_read_model, project_appended_events}; diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs index 38333b9c0..794c45ba7 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs @@ -1,13 +1,11 @@ use std::collections::BTreeMap; -use std::pin::Pin; -use std::time::Duration; use async_nats::jetstream::{ self, - consumer::{AckPolicy, DeliverPolicy, ReplayPolicy, pull}, + consumer::{DeliverPolicy, ReplayPolicy, pull}, kv, }; -use futures::{Stream, StreamExt}; +use futures::StreamExt; use trogon_decider_nats::record_stream_message; use trogon_decider_runtime::{Event, EventData, EventDecode, StreamEvent, StreamPosition}; use trogon_nats::SubjectTokenViolation; @@ -26,40 +24,23 @@ use crate::{ v1, }; -pub type ScheduleWatchStream = Pin + Send + 'static>>; -pub type LoadAndWatchSchedulesResult = Result<(Vec, ScheduleWatchStream), SchedulerError>; - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Clone)] -pub enum ScheduleChange { - Put(Schedule), - Delete(String), -} - #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] -pub enum ProjectionChange { +enum ProjectionChange { Upsert(Schedule), Delete(String), } -#[derive(Debug, Clone, PartialEq)] -struct WatchedProjectionChange { - stream_id: String, - next_state: ScheduleStreamState, - change: Option, -} - #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] -pub enum ScheduleStreamState { +enum ScheduleStreamState { Initial, Present(Schedule), Deleted(String), } #[derive(Debug)] -pub enum ScheduleTransitionError { +enum ScheduleTransitionError { InvalidEventId { id: String, source: SubjectTokenViolation }, MismatchedEventScheduleId { stream_id: String, schedule_id: String }, MalformedEvent { context: &'static str }, @@ -70,11 +51,11 @@ pub enum ScheduleTransitionError { DeletedScheduleForRemoval { id: String }, } -pub const fn initial_state() -> ScheduleStreamState { +const fn initial_state() -> ScheduleStreamState { ScheduleStreamState::Initial } -pub fn apply( +fn apply( stream_id: &str, state: ScheduleStreamState, event: &v1::ScheduleEvent, @@ -288,7 +269,7 @@ fn project_message(message: &v1::Message) -> MessageEnvelope { } } -pub fn projection_change(before: &ScheduleStreamState, after: &ScheduleStreamState) -> Option { +fn projection_change(before: &ScheduleStreamState, after: &ScheduleStreamState) -> Option { match (before, after) { (ScheduleStreamState::Initial, ScheduleStreamState::Initial) => None, (_, ScheduleStreamState::Present(spec)) => Some(ProjectionChange::Upsert(spec.clone())), @@ -301,16 +282,6 @@ pub fn projection_change(before: &ScheduleStreamState, after: &ScheduleStreamSta } } -impl ScheduleStreamState { - pub fn into_job(self) -> Option { - match self { - Self::Initial => None, - Self::Deleted(_) => None, - Self::Present(job) => Some(job), - } - } -} - impl From for ScheduleStreamState { fn from(job: Schedule) -> Self { Self::Present(job) @@ -360,76 +331,6 @@ impl std::error::Error for ScheduleTransitionError { } } -pub async fn load_and_watch_schedules(js: &J) -> LoadAndWatchSchedulesResult -where - J: JetStreamGetKeyValue + JetStreamGetStream, -{ - let stream: jetstream::stream::Stream = open_events_stream(js).await?; - let info = stream - .get_info() - .await - .map_err(|source| SchedulerError::event_source("failed to query events stream info", source))?; - let last_sequence = info.state.last_sequence; - let initial_jobs = rebuild_jobs_from_stream(&stream, info.state.first_sequence, last_sequence).await?; - rewrite_schedules_projection(js, &initial_jobs).await?; - let consumer = stream - .create_consumer(event_watch_consumer_config(next_watch_start_sequence(last_sequence))) - .await - .map_err(|source| SchedulerError::event_source("failed to create schedule event watch consumer", source))?; - let subscriber = consumer - .messages() - .await - .map_err(|source| SchedulerError::event_source("failed to open schedule event watch stream", source))?; - - let kv: kv::Store = open_schedules_bucket(js).await?; - let state = initial_jobs - .iter() - .cloned() - .map(|job| (job.id.to_string(), ScheduleStreamState::Present(job))) - .collect::>(); - let watcher: ScheduleWatchStream = Box::pin(futures::stream::unfold( - (state, subscriber, kv), - |(mut state, mut subscriber, kv)| async move { - loop { - let result = subscriber.next().await?; - let message = match result { - Ok(message) => message, - Err(error) => { - tracing::error!(error = %error, "Failed to read schedule event from watch consumer"); - continue; - } - }; - let Some(projection_change) = prepare_watched_projection_change(&state, &message) else { - ack_watch_message(&message).await; - continue; - }; - - let WatchedProjectionChange { - stream_id, - next_state, - change, - } = projection_change; - - if let Some(change) = change.as_ref() - && let Err(error) = apply_projection_change(&kv, change).await - { - tracing::error!(error = %error, "Failed to update projected schedules state from event"); - nak_watch_message(&message).await; - continue; - } - - commit_watched_projection_state(&mut state, stream_id, next_state); - ack_watch_message(&message).await; - if let Some(change) = change { - return Some((change_from_projection_change(change), (state, subscriber, kv))); - } - } - }, - )); - - Ok((initial_jobs, watcher)) -} - pub(crate) async fn catch_up_schedules_read_model(js: &J) -> Result<(), SchedulerError> where J: JetStreamGetKeyValue + JetStreamGetStream, @@ -476,7 +377,7 @@ where break; } let reached_tail = sequence >= info.state.last_sequence; - let event = decode_recorded_watch_message(&message)?; + let event = decode_recorded_delivery_message(&message)?; let data = event.decode::().map_err(|source| { SchedulerError::event_source( "failed to decode schedule event during schedules read-model catch-up", @@ -538,92 +439,6 @@ pub(crate) async fn project_appended_events( maybe_advance_read_model_checkpoint(bucket, final_position.as_u64()).await } -async fn rewrite_schedules_projection(js: &J, jobs: &[Schedule]) -> Result<(), SchedulerError> -where - J: JetStreamGetKeyValue, -{ - let kv: kv::Store = open_schedules_bucket(js).await?; - let desired_ids = jobs - .iter() - .map(|job| job.id.as_str()) - .collect::>(); - let mut keys = kv - .keys() - .await - .map_err(|source| SchedulerError::kv_source("failed to list projection keys", source))?; - - while let Some(result) = keys.next().await { - let key = result.map_err(|source| SchedulerError::kv_source("failed to read projection key", source))?; - if is_read_model_metadata_key(&key) { - continue; - } - if desired_ids.contains(key.as_str()) { - continue; - } - kv.delete(key) - .await - .map_err(|source| SchedulerError::kv_source("failed to delete stale projected job state", source))?; - } - - for job in jobs { - let value = serde_json::to_vec(job)?; - kv.put(job.id.to_string(), value.into()) - .await - .map_err(|source| SchedulerError::kv_source("failed to write projected job state", source))?; - } - - Ok(()) -} - -async fn rebuild_jobs_from_stream( - stream: &jetstream::stream::Stream, - first_sequence: u64, - last_sequence: u64, -) -> Result, SchedulerError> { - let mut states = BTreeMap::new(); - if last_sequence == 0 || first_sequence == 0 || first_sequence > last_sequence { - return Ok(Vec::new()); - } - - let consumer = stream - .create_consumer(event_replay_consumer_config(first_sequence)) - .await - .map_err(|source| { - SchedulerError::event_source("failed to create schedule projection replay consumer", source) - })?; - let mut messages = consumer - .messages() - .await - .map_err(|source| SchedulerError::event_source("failed to open schedule projection replay stream", source))?; - - while let Some(message) = messages.next().await { - let message = message - .map_err(|source| SchedulerError::event_source("failed to read schedule event from stream", source))?; - let sequence = event_message_sequence(&message, "failed to read schedule event metadata")?; - if sequence > last_sequence { - break; - } - let reached_tail = sequence >= last_sequence; - let event = decode_recorded_watch_message(&message)?; - let data = event.decode::().map_err(|source| { - SchedulerError::event_source("failed to decode recorded schedule event payload", source) - })?; - let Some(data) = data.into_decoded() else { - if reached_tail { - break; - } - continue; - }; - let stream_id = schedule_id_from_event_subject(event.stream_id())?; - apply_event_to_read_model_state(&mut states, &stream_id, &data)?; - if reached_tail { - break; - } - } - - Ok(states.into_values().filter_map(ScheduleStreamState::into_job).collect()) -} - fn decode_recorded_job_event( message: async_nats::jetstream::message::StreamMessage, ) -> Result { @@ -632,29 +447,15 @@ fn decode_recorded_job_event( .map_err(|source| SchedulerError::event_source("failed to decode stored schedule event", source)) } -fn decode_recorded_watch_message(message: &async_nats::jetstream::Message) -> Result { +fn decode_recorded_delivery_message(message: &async_nats::jetstream::Message) -> Result { let stream_message = async_nats::jetstream::message::StreamMessage::try_from(message.message.clone()).map_err(|source| { - SchedulerError::event_source("failed to reconstruct stream message from watch delivery", source) + SchedulerError::event_source("failed to reconstruct stream message from event delivery", source) })?; decode_recorded_job_event(stream_message) } -fn next_watch_start_sequence(last_sequence: u64) -> u64 { - last_sequence.saturating_add(1).max(1) -} - -fn event_watch_consumer_config(start_sequence: u64) -> pull::Config { - pull::Config { - deliver_policy: DeliverPolicy::ByStartSequence { start_sequence }, - ack_policy: AckPolicy::Explicit, - replay_policy: ReplayPolicy::Instant, - inactive_threshold: Duration::from_secs(30), - ..Default::default() - } -} - fn event_replay_consumer_config(start_sequence: u64) -> pull::OrderedConfig { pull::OrderedConfig { deliver_policy: DeliverPolicy::ByStartSequence { start_sequence }, @@ -663,89 +464,6 @@ fn event_replay_consumer_config(start_sequence: u64) -> pull::OrderedConfig { } } -fn prepare_watched_projection_change( - state: &BTreeMap, - message: &jetstream::Message, -) -> Option { - let event = match decode_recorded_watch_message(message) { - Ok(event) => event, - Err(error) => { - tracing::error!(error = %error, "Failed to decode schedule event from watcher"); - return None; - } - }; - - let data = match event.decode::() { - Ok(data) => data, - Err(error) => { - tracing::error!(error = %error, "Failed to decode watched schedule event payload"); - return None; - } - }; - let data = data.into_decoded()?; - - let stream_id = match schedule_id_from_event_subject(event.stream_id()) { - Ok(stream_id) => stream_id, - Err(error) => { - tracing::error!(error = %error, "Failed to derive watched schedule stream id from subject"); - return None; - } - }; - - prepare_projection_change(state, stream_id.as_str(), &data) -} - -fn prepare_projection_change( - state: &BTreeMap, - stream_id: &str, - event: &v1::ScheduleEvent, -) -> Option { - let current = state.get(stream_id).cloned().unwrap_or_else(initial_state); - let next = match apply(stream_id, current.clone(), event) - .map_err(|error| SchedulerError::event_source("failed to apply watched schedule event to stream state", error)) - { - Ok(next) => next, - Err(error) => { - tracing::error!(error = %error, "Failed to apply schedule event to current state"); - return None; - } - }; - let change = projection_change(¤t, &next); - - Some(WatchedProjectionChange { - stream_id: stream_id.to_string(), - next_state: next, - change, - }) -} - -fn commit_watched_projection_state( - state: &mut BTreeMap, - stream_id: String, - next: ScheduleStreamState, -) { - match next { - ScheduleStreamState::Present(_) | ScheduleStreamState::Deleted(_) => { - state.insert(stream_id, next); - } - ScheduleStreamState::Initial => { - state.remove(stream_id.as_str()); - } - } -} - -async fn ack_watch_message(message: &jetstream::Message) { - if let Err(error) = message.ack().await { - tracing::error!(error = %error, "Failed to acknowledge watched schedule event"); - } -} - -async fn nak_watch_message(message: &jetstream::Message) { - if let Err(error) = message.ack_with(jetstream::AckKind::Nak(None)).await { - tracing::error!(error = %error, "Failed to negatively acknowledge watched schedule event"); - } -} - fn event_message_sequence(message: &jetstream::Message, context: &'static str) -> Result { message .info() @@ -847,13 +565,6 @@ async fn apply_projection_change(kv: &kv::Store, change: &ProjectionChange) -> R Ok(()) } -fn change_from_projection_change(change: ProjectionChange) -> ScheduleChange { - match change { - ProjectionChange::Upsert(job) => ScheduleChange::Put(job), - ProjectionChange::Delete(id) => ScheduleChange::Delete(id), - } -} - fn apply_event_to_read_model_state( states: &mut BTreeMap, stream_id: &str, @@ -1115,61 +826,12 @@ mod tests { )); } - #[test] - fn watched_projection_change_does_not_mutate_state_before_commit() { - let mut state = BTreeMap::new(); - let prepared = prepare_projection_change(&state, "backup", &added_event("backup")).unwrap(); - - assert!(state.is_empty()); - assert_eq!( - prepared.change, - Some(ProjectionChange::Upsert(expected_schedule("backup"))) - ); - - commit_watched_projection_state(&mut state, prepared.stream_id, prepared.next_state); - - assert!(matches!(state.get("backup"), Some(ScheduleStreamState::Present(_)))); - } - - #[test] - fn watched_projection_commits_tombstone_even_without_public_change() { - let mut state = BTreeMap::new(); - let prepared = prepare_projection_change(&state, "backup", &removed_event("backup")).unwrap(); - - assert!(prepared.change.is_none()); - - commit_watched_projection_state(&mut state, prepared.stream_id, prepared.next_state); - - assert_eq!( - state.get("backup"), - Some(&ScheduleStreamState::Deleted("backup".to_string())) - ); - } - #[test] fn initial_removal_creates_deleted_tombstone() { let state = apply("backup", initial_state(), &removed_event("backup")).unwrap(); assert_eq!(state, ScheduleStreamState::Deleted("backup".to_string())); } - #[test] - fn watch_start_sequence_moves_past_bootstrap_tail() { - assert_eq!(next_watch_start_sequence(0), 1); - assert_eq!(next_watch_start_sequence(41), 42); - } - - #[test] - fn watch_consumer_replays_only_after_bootstrap_boundary() { - let config = event_watch_consumer_config(42); - - assert_eq!( - config.deliver_policy, - DeliverPolicy::ByStartSequence { start_sequence: 42 } - ); - assert_eq!(config.ack_policy, AckPolicy::Explicit); - assert_eq!(config.replay_policy, ReplayPolicy::Instant); - } - #[test] fn read_model_state_rejects_recreating_deleted_job() { let mut states = BTreeMap::new(); From 5c0991def2e45f5fd9a301195cd107715c1d14eb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 18 Jun 2026 01:51:40 -0400 Subject: [PATCH 08/11] fix(scheduler): reconcile rebased projection surface Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 1 + rsworkspace/Cargo.toml | 5 + .../crates/trogon-scheduler/Cargo.toml | 1 + .../crates/trogon-scheduler/src/config.rs | 102 ++++++++++ .../crates/trogon-scheduler/src/mocks.rs | 5 + .../src/projections/schedules.rs | 29 +++ .../trogon-scheduler/src/queries/get.rs | 30 +++ .../trogon-scheduler/src/queries/list.rs | 35 ++++ .../src/queries/schedule_id.rs | 104 ++++++++++ .../src/read_model/message.rs | 177 ++++++++++++++++++ .../trogon-scheduler/src/read_model/mod.rs | 15 ++ .../src/read_model/schedule.rs | 31 +++ .../src/read_model/schedule_details.rs | 9 + .../src/read_model/schedule_event_delivery.rs | 15 ++ .../schedule_event_sampling_source.rs | 7 + .../src/read_model/schedule_event_schedule.rs | 29 +++ .../src/read_model/schedule_event_status.rs | 9 + .../trogon-scheduler/src/store/connect.rs | 40 ++++ .../trogon-scheduler/src/store/event_store.rs | 128 +++++++++++++ .../crates/trogon-scheduler/src/store/mod.rs | 6 + 20 files changed, 778 insertions(+) create mode 100644 rsworkspace/crates/trogon-scheduler/src/config.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/queries/get.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/queries/list.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/message.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/mod.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/schedule_details.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_sampling_source.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_status.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/store/connect.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/store/event_store.rs create mode 100644 rsworkspace/crates/trogon-scheduler/src/store/mod.rs diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index dca59b1a5..ef41703fa 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -4371,6 +4371,7 @@ dependencies = [ "trogon-decider-runtime", "trogon-nats", "trogon-std", + "trogon-telemetry", "trogonai-proto", "uuid", ] diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index bcea82ced..5a53137a0 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -23,9 +23,12 @@ mcp-nats = { path = "crates/mcp-nats" } mcp-nats-server = { path = "crates/mcp-nats-server" } mcp-nats-stdio = { path = "crates/mcp-nats-stdio" } trogon-nats = { path = "crates/trogon-nats" } +trogon-decider = { path = "crates/trogon-decider" } +trogon-decider-nats = { path = "crates/trogon-decider-nats" } trogon-decider-runtime = { path = "crates/trogon-decider-runtime" } trogon-service-config = { path = "crates/trogon-service-config" } trogon-std = { path = "crates/trogon-std" } +trogonai-proto = { path = "crates/trogonai-proto" } # A2A a2a = { package = "a2a-lf", version = "=0.3.0" } @@ -85,8 +88,10 @@ tonic = { version = "=0.14.6", default-features = false, features = ["transport" moka = { version = "=0.12.10", features = ["future"] } # Misc +chrono-tz = "=0.10.4" http = "=1.4.0" reqwest = { version = "=0.12.28", default-features = false, features = ["json", "rustls-tls", "stream"] } +rrule = "=0.14.0" sha2 = "=0.10.8" wiremock = "=0.6.3" tempfile = "=3.27.0" diff --git a/rsworkspace/crates/trogon-scheduler/Cargo.toml b/rsworkspace/crates/trogon-scheduler/Cargo.toml index 4f1854a20..a7b67de21 100644 --- a/rsworkspace/crates/trogon-scheduler/Cargo.toml +++ b/rsworkspace/crates/trogon-scheduler/Cargo.toml @@ -55,6 +55,7 @@ trogon-decider = { workspace = true, features = ["test-support"] } trogon-decider-runtime = { workspace = true, features = ["test-support"] } trogon-decider-nats = { workspace = true } trogon-nats = { workspace = true, features = ["test-support"] } +trogon-std = { workspace = true, features = ["test-support"] } [[test]] name = "schedule_unit" diff --git a/rsworkspace/crates/trogon-scheduler/src/config.rs b/rsworkspace/crates/trogon-scheduler/src/config.rs new file mode 100644 index 000000000..4eec192a0 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/config.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; +use trogon_decider_runtime::{StreamPosition, StreamWritePrecondition}; + +use crate::error::SchedulerError; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "mode", content = "position", rename_all = "snake_case")] +pub enum ScheduleWriteCondition { + MustNotExist, + MustBeAtPosition(StreamPosition), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScheduleWriteState { + current_position: Option, + exists: bool, +} + +impl ScheduleWriteState { + pub const fn new(current_position: Option, exists: bool) -> Self { + Self { + current_position, + exists, + } + } + + pub const fn current_position(self) -> Option { + self.current_position + } + + pub const fn exists(self) -> bool { + self.exists + } +} + +impl ScheduleWriteCondition { + pub fn ensure(self, id: &str, state: ScheduleWriteState) -> Result<(), SchedulerError> { + match self { + Self::MustNotExist if !state.exists() => Ok(()), + Self::MustBeAtPosition(expected) if state.current_position() == Some(expected) => Ok(()), + expected => Err(SchedulerError::OptimisticConcurrencyConflict { + id: id.to_string(), + expected: expected.into(), + current_position: state.current_position(), + }), + } + } +} + +impl From for StreamWritePrecondition { + fn from(value: ScheduleWriteCondition) -> Self { + match value { + ScheduleWriteCondition::MustNotExist => Self::NoStream, + ScheduleWriteCondition::MustBeAtPosition(position) => Self::At(position), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn position(value: u64) -> StreamPosition { + StreamPosition::try_new(value).expect("test stream position must be non-zero") + } + + #[test] + fn write_condition_ensures_expected_positions() { + ScheduleWriteCondition::MustNotExist + .ensure("alpha", ScheduleWriteState::new(None, false)) + .unwrap(); + ScheduleWriteCondition::MustBeAtPosition(position(3)) + .ensure("alpha", ScheduleWriteState::new(Some(position(3)), true)) + .unwrap(); + + let error = ScheduleWriteCondition::MustBeAtPosition(position(2)) + .ensure("alpha", ScheduleWriteState::new(Some(position(4)), true)) + .unwrap_err(); + assert!(matches!( + error, + SchedulerError::OptimisticConcurrencyConflict { + current_position: Some(_), + .. + } + )); + } + + #[test] + fn write_condition_rejects_reusing_deleted_stream_ids() { + let error = ScheduleWriteCondition::MustNotExist + .ensure("alpha", ScheduleWriteState::new(Some(position(7)), true)) + .unwrap_err(); + + assert!(matches!( + error, + SchedulerError::OptimisticConcurrencyConflict { + current_position: Some(_), + .. + } + )); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index 7aca5ee7c..5fc73b99b 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -491,6 +491,11 @@ impl StreamAppend for MockSchedulerStore { Some(ScheduleEventCase::ScheduleRemoved(_)) => { projected_schedule = None; } + Some( + ScheduleEventCase::ScheduleOccurrenceRecorded(_) + | ScheduleEventCase::ScheduleOccurrenceScheduled(_) + | ScheduleEventCase::ScheduleCompleted(_), + ) => {} None => { return Err(SchedulerError::event_source( "failed to project mocked schedule event without supported case", diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs index 794c45ba7..182a742e8 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs @@ -80,6 +80,16 @@ fn apply( id: stream_id.to_string(), }) } + ( + ScheduleStreamState::Initial, + Some( + ScheduleEventCase::ScheduleOccurrenceRecorded(_) + | ScheduleEventCase::ScheduleOccurrenceScheduled(_) + | ScheduleEventCase::ScheduleCompleted(_), + ), + ) => Err(ScheduleTransitionError::MissingScheduleForStateChange { + id: stream_id.to_string(), + }), (ScheduleStreamState::Initial, Some(ScheduleEventCase::ScheduleRemoved(_))) => { Ok(ScheduleStreamState::Deleted(stream_id.to_string())) } @@ -97,6 +107,14 @@ fn apply( (ScheduleStreamState::Present(job), Some(ScheduleEventCase::ScheduleRemoved(_))) => { Ok(ScheduleStreamState::Deleted(job.id)) } + ( + ScheduleStreamState::Present(job), + Some( + ScheduleEventCase::ScheduleOccurrenceRecorded(_) + | ScheduleEventCase::ScheduleOccurrenceScheduled(_) + | ScheduleEventCase::ScheduleCompleted(_), + ), + ) => Ok(ScheduleStreamState::Present(job)), (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::ScheduleCreated(_))) => { Err(ScheduleTransitionError::CannotAddDeletedSchedule { id }) } @@ -109,6 +127,14 @@ fn apply( (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::ScheduleRemoved(_))) => { Err(ScheduleTransitionError::DeletedScheduleForRemoval { id }) } + ( + ScheduleStreamState::Deleted(id), + Some( + ScheduleEventCase::ScheduleOccurrenceRecorded(_) + | ScheduleEventCase::ScheduleOccurrenceScheduled(_) + | ScheduleEventCase::ScheduleCompleted(_), + ), + ) => Err(ScheduleTransitionError::DeletedScheduleForStateChange { id }), (_, None) => Err(ScheduleTransitionError::MalformedEvent { context: "schedule event has no supported case", }), @@ -142,6 +168,9 @@ fn event_schedule_id(event: &v1::ScheduleEvent) -> Option<&str> { Some(ScheduleEventCase::SchedulePaused(inner)) => Some(&inner.schedule_id), Some(ScheduleEventCase::ScheduleResumed(inner)) => Some(&inner.schedule_id), Some(ScheduleEventCase::ScheduleRemoved(inner)) => Some(&inner.schedule_id), + Some(ScheduleEventCase::ScheduleOccurrenceRecorded(inner)) => Some(&inner.schedule_id), + Some(ScheduleEventCase::ScheduleOccurrenceScheduled(inner)) => Some(&inner.schedule_id), + Some(ScheduleEventCase::ScheduleCompleted(inner)) => Some(&inner.schedule_id), None => None, } } diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs new file mode 100644 index 000000000..bb40c9e87 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs @@ -0,0 +1,30 @@ +use async_nats::jetstream::kv; + +use crate::{error::SchedulerError, read_model::Schedule}; + +use super::ScheduleId; + +#[derive(Debug, Clone)] +pub struct GetSchedule { + pub id: ScheduleId, +} + +impl GetSchedule { + pub const fn new(id: ScheduleId) -> Self { + Self { id } + } +} + +pub async fn run(store: &kv::Store, command: GetSchedule) -> Result, SchedulerError> { + let Some(entry) = store + .entry(command.id.as_str()) + .await + .map_err(|source| SchedulerError::kv_source("failed to read projected schedule", source))? + else { + return Ok(None); + }; + + serde_json::from_slice(&entry.value) + .map(Some) + .map_err(SchedulerError::from) +} diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/list.rs b/rsworkspace/crates/trogon-scheduler/src/queries/list.rs new file mode 100644 index 000000000..d5ae998ca --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/queries/list.rs @@ -0,0 +1,35 @@ +use futures::StreamExt; + +use async_nats::jetstream::kv; + +use crate::{error::SchedulerError, kv::SCHEDULES_CHECKPOINT_KEY, read_model::Schedule}; + +#[derive(Debug, Clone, Default)] +pub struct ListSchedules; + +pub async fn run(store: &kv::Store, _command: ListSchedules) -> Result, SchedulerError> { + let mut keys = store + .keys() + .await + .map_err(|source| SchedulerError::kv_source("failed to list projected schedule keys", source))?; + let mut jobs = Vec::new(); + + while let Some(result) = keys.next().await { + let key = + result.map_err(|source| SchedulerError::kv_source("failed to read projected schedule key", source))?; + if key == SCHEDULES_CHECKPOINT_KEY { + continue; + } + let Some(entry) = store + .entry(key) + .await + .map_err(|source| SchedulerError::kv_source("failed to read projected schedule value", source))? + else { + continue; + }; + let job = serde_json::from_slice(&entry.value).map_err(SchedulerError::from)?; + jobs.push(job); + } + + Ok(jobs) +} diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs b/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs new file mode 100644 index 000000000..2bb8960c3 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs @@ -0,0 +1,104 @@ +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use trogon_nats::{NatsToken, SubjectTokenViolation}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScheduleId(NatsToken); + +#[derive(Debug)] +pub struct ScheduleIdError { + raw: String, + source: SubjectTokenViolation, +} + +impl ScheduleId { + pub fn parse(raw: &str) -> Result { + NatsToken::new(raw) + .map(Self) + .map_err(|source| ScheduleIdError::new(raw, source)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn as_token(&self) -> &NatsToken { + &self.0 + } +} + +impl AsRef for ScheduleId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for ScheduleId { + type Err = ScheduleIdError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl Serialize for ScheduleId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ScheduleId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl ScheduleIdError { + fn new(raw: &str, source: SubjectTokenViolation) -> Self { + Self { + raw: raw.to_string(), + source, + } + } +} + +impl fmt::Display for ScheduleId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Display for ScheduleIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "schedule id '{}' is invalid: {}", self.raw, self.source) + } +} + +impl std::error::Error for ScheduleIdError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_schedule_id_preserves_subject_token_violation_as_source() { + let error = ScheduleId::parse("").unwrap_err(); + + let source = std::error::Error::source(&error).unwrap(); + + assert_eq!(source.to_string(), SubjectTokenViolation::Empty.to_string()); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs new file mode 100644 index 000000000..d0f5b5975 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs @@ -0,0 +1,177 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MessageHeadersError { + InvalidName { name: String }, + InvalidValue { name: String }, +} + +impl std::fmt::Display for MessageHeadersError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidName { name } => write!(f, "header name '{name}' is invalid"), + Self::InvalidValue { name } => write!(f, "header '{name}' contains an invalid value"), + } + } +} + +impl std::error::Error for MessageHeadersError {} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MessageHeaders(Vec<(String, String)>); + +impl MessageHeaders { + pub fn new(headers: I) -> Result + where + I: IntoIterator, + N: Into, + V: Into, + { + let headers = headers + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect::>(); + + for (name, value) in &headers { + if name.trim().is_empty() + || name.contains(':') + || name.chars().any(|ch| ch.is_control() || ch.is_whitespace()) + { + return Err(MessageHeadersError::InvalidName { name: name.clone() }); + } + if value.chars().any(|ch| ch == '\r' || ch == '\n' || ch == '\0') { + return Err(MessageHeadersError::InvalidValue { name: name.clone() }); + } + } + + Ok(Self(headers)) + } + + pub fn from_pairs(headers: I) -> Self + where + I: IntoIterator, + N: Into, + V: Into, + { + Self( + headers + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + ) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn as_slice(&self) -> &[(String, String)] { + &self.0 + } + + pub fn into_vec(self) -> Vec<(String, String)> { + self.0 + } +} + +impl TryFrom> for MessageHeaders { + type Error = MessageHeadersError; + + fn try_from(value: Vec<(String, String)>) -> Result { + Self::new(value) + } +} + +impl Serialize for MessageHeaders { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for MessageHeaders { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let headers = Vec::<(String, String)>::deserialize(deserializer)?; + Self::new(headers).map_err(D::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct MessageEnvelope { + pub content: MessageContent, + #[serde(default, skip_serializing_if = "MessageHeaders::is_empty")] + pub headers: MessageHeaders, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct MessageContent(String); + +impl MessageContent { + pub fn new(content: impl Into) -> Self { + Self(content.into()) + } + + pub fn from_static(content: &'static str) -> Self { + Self(content.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn as_slice(&self) -> &[u8] { + self.0.as_bytes() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl From for MessageContent { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for MessageContent { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl AsRef<[u8]> for MessageContent { + fn as_ref(&self) -> &[u8] { + self.as_slice() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_preserve_ordered_pairs() { + let headers = MessageHeaders::new([("x-kind", "heartbeat"), ("x-kind", "retry"), ("x-owner", "ops")]).unwrap(); + + assert_eq!( + headers.as_slice(), + &[ + ("x-kind".to_string(), "heartbeat".to_string()), + ("x-kind".to_string(), "retry".to_string()), + ("x-owner".to_string(), "ops".to_string()), + ] + ); + } + + #[test] + fn invalid_header_name_is_rejected() { + let error = MessageHeaders::new([("bad name", "value")]).unwrap_err(); + assert!(error.to_string().contains("invalid")); + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/mod.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/mod.rs new file mode 100644 index 000000000..e349c1b63 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/mod.rs @@ -0,0 +1,15 @@ +mod message; +mod schedule; +mod schedule_details; +mod schedule_event_delivery; +mod schedule_event_sampling_source; +mod schedule_event_schedule; +mod schedule_event_status; + +pub use message::{MessageContent, MessageEnvelope, MessageHeaders, MessageHeadersError}; +pub use schedule::Schedule; +pub use schedule_details::ScheduleDetails; +pub use schedule_event_delivery::ScheduleEventDelivery; +pub use schedule_event_sampling_source::ScheduleEventSamplingSource; +pub use schedule_event_schedule::ScheduleEventSchedule; +pub use schedule_event_status::ScheduleEventStatus; diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs new file mode 100644 index 000000000..1448404ab --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +use super::{MessageEnvelope, ScheduleDetails, ScheduleEventDelivery, ScheduleEventSchedule, ScheduleEventStatus}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Schedule { + pub id: String, + #[serde(default, rename = "state")] + pub status: ScheduleEventStatus, + pub schedule: ScheduleEventSchedule, + pub delivery: ScheduleEventDelivery, + pub message: MessageEnvelope, +} + +impl Schedule { + pub fn is_enabled(&self) -> bool { + matches!(self.status, ScheduleEventStatus::Scheduled) + } +} + +impl From<(String, ScheduleDetails)> for Schedule { + fn from((id, details): (String, ScheduleDetails)) -> Self { + Self { + id, + status: details.status, + schedule: details.schedule, + delivery: details.delivery, + message: details.message, + } + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_details.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_details.rs new file mode 100644 index 000000000..e7844b2c8 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_details.rs @@ -0,0 +1,9 @@ +use super::{MessageEnvelope, ScheduleEventDelivery, ScheduleEventSchedule, ScheduleEventStatus}; + +#[derive(Debug, Clone, PartialEq)] +pub struct ScheduleDetails { + pub status: ScheduleEventStatus, + pub schedule: ScheduleEventSchedule, + pub delivery: ScheduleEventDelivery, + pub message: MessageEnvelope, +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs new file mode 100644 index 000000000..3fe6d66b9 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use super::ScheduleEventSamplingSource; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ScheduleEventDelivery { + NatsMessage { + subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + ttl_sec: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + }, +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_sampling_source.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_sampling_source.rs new file mode 100644 index 000000000..d88055406 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_sampling_source.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ScheduleEventSamplingSource { + LatestFromSubject { subject: String }, +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs new file mode 100644 index 000000000..7c7cd15aa --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ScheduleEventSchedule { + At { + at: DateTime, + }, + Every { + every_sec: u64, + }, + Cron { + expr: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + timezone: Option, + }, + #[serde(rename = "rrule")] + RRule { + dtstart: DateTime, + rrule: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + timezone: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + rdate: Vec>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + exdate: Vec>, + }, +} diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_status.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_status.rs new file mode 100644 index 000000000..10e553c37 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_status.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ScheduleEventStatus { + #[default] + Scheduled, + Paused, +} diff --git a/rsworkspace/crates/trogon-scheduler/src/store/connect.rs b/rsworkspace/crates/trogon-scheduler/src/store/connect.rs new file mode 100644 index 000000000..2b3bebf97 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/store/connect.rs @@ -0,0 +1,40 @@ +#![cfg_attr(coverage, allow(dead_code, unused_imports))] + +use async_nats::jetstream::{self, kv}; + +use crate::{ + error::SchedulerError, + kv::{get_or_create_command_snapshot_bucket, get_or_create_events_stream, get_or_create_schedules_bucket}, + nats::validate_events_stream, + projections::catch_up_schedules_read_model, +}; + +use super::event_store::EventStore; + +#[derive(Clone)] +pub struct Store { + pub event_store: EventStore, + pub schedules_bucket: kv::Store, +} + +#[cfg(not(coverage))] +pub async fn connect_store(nats: async_nats::Client) -> Result { + let js = jetstream::new(nats); + let schedules_bucket = get_or_create_schedules_bucket(&js).await?; + let command_snapshot_bucket = get_or_create_command_snapshot_bucket(&js).await?; + let events_stream = get_or_create_events_stream(&js).await?; + validate_events_stream(&events_stream)?; + catch_up_schedules_read_model(&js).await?; + Ok(Store { + event_store: EventStore::new(js, events_stream, command_snapshot_bucket, schedules_bucket.clone()), + schedules_bucket, + }) +} + +#[cfg(coverage)] +pub async fn connect_store(_nats: async_nats::Client) -> Result { + Err(SchedulerError::event_source( + "coverage stub does not provision the cron store", + std::io::Error::other("coverage"), + )) +} diff --git a/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs b/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs new file mode 100644 index 000000000..df710865f --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs @@ -0,0 +1,128 @@ +use async_nats::jetstream::{self, kv}; +use trogon_decider_nats::{ + JetStreamStore, NatsSnapshotConfig, StreamSubject, StreamSubjectResolver, SubjectState, subject_current_position, +}; +use trogon_decider_runtime::{ + AppendStreamRequest, AppendStreamResponse, ReadSnapshotRequest, ReadSnapshotResponse, ReadStreamRequest, + ReadStreamResponse, SnapshotPayloadDecode, SnapshotPayloadEncode, SnapshotRead, SnapshotType, SnapshotWrite, + StreamAppend, StreamRead, WriteSnapshotRequest, WriteSnapshotResponse, +}; + +use crate::{ + config::ScheduleWriteState, + error::SchedulerError, + nats::{event_subject, resolve_event_subject_state}, + projections::project_appended_events, +}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ScheduleEventSubjectResolver; + +impl StreamSubjectResolver for ScheduleEventSubjectResolver { + type Error = SchedulerError; + + async fn resolve_subject_state( + &self, + events_stream: &jetstream::stream::Stream, + stream_id: &str, + ) -> Result { + let subject = StreamSubject::new(event_subject(stream_id)) + .map_err(|source| SchedulerError::event_source("failed to resolve schedule event subject", source))?; + let current_position = subject_current_position(events_stream, &subject) + .await + .map_err(|source| SchedulerError::event_source("failed to read latest stream position", source))?; + let canonical_state = current_position.map(|position| ScheduleWriteState::new(Some(position), true)); + let state = resolve_event_subject_state(canonical_state); + + Ok(SubjectState { + subject, + current_position: state.write_state.current_position(), + }) + } +} + +#[derive(Clone)] +pub struct EventStore { + inner: JetStreamStore, + schedules_bucket: kv::Store, +} + +impl EventStore { + pub fn new( + js: jetstream::Context, + events_stream: jetstream::stream::Stream, + command_snapshot_bucket: kv::Store, + schedules_bucket: kv::Store, + ) -> Self { + Self { + inner: JetStreamStore::builder(js, events_stream, command_snapshot_bucket) + .with_snapshot_config(NatsSnapshotConfig::without_checkpoint()) + .with_subject_resolver(ScheduleEventSubjectResolver), + schedules_bucket, + } + } + + pub fn events_stream(&self) -> &jetstream::stream::Stream { + self.inner.events_stream() + } +} + +impl StreamRead for EventStore { + type Error = SchedulerError; + + async fn read_stream(&self, request: ReadStreamRequest<'_, str>) -> Result { + self.inner.read_stream(request).await.map_err(SchedulerError::from) + } +} + +impl StreamAppend for EventStore { + type Error = SchedulerError; + + async fn append_stream(&self, request: AppendStreamRequest<'_, str>) -> Result { + let stream_id = request.stream_id; + let projected_events = request.events.clone(); + let outcome = self.inner.append_stream(request).await.map_err(SchedulerError::from)?; + + project_appended_events( + &self.schedules_bucket, + stream_id, + projected_events.as_slice(), + outcome.stream_position, + ) + .await?; + + Ok(outcome) + } +} + +impl SnapshotRead for EventStore +where + Payload: SnapshotPayloadDecode + SnapshotType + Send, + ::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, +{ + type Error = SchedulerError; + + async fn read_snapshot( + &self, + request: ReadSnapshotRequest<'_, str>, + ) -> Result, Self::Error> { + self.inner.read_snapshot(request).await.map_err(SchedulerError::from) + } +} + +impl SnapshotWrite for EventStore +where + Payload: SnapshotPayloadEncode + SnapshotType + Send, + ::Error: std::error::Error + Send + Sync + 'static, + ::Error: std::error::Error + Send + Sync + 'static, +{ + type Error = SchedulerError; + + async fn write_snapshot( + &self, + request: WriteSnapshotRequest<'_, Payload, str>, + ) -> Result { + self.inner.write_snapshot(request).await.map_err(SchedulerError::from) + } +} diff --git a/rsworkspace/crates/trogon-scheduler/src/store/mod.rs b/rsworkspace/crates/trogon-scheduler/src/store/mod.rs new file mode 100644 index 000000000..a875c1d15 --- /dev/null +++ b/rsworkspace/crates/trogon-scheduler/src/store/mod.rs @@ -0,0 +1,6 @@ +mod connect; +mod event_store; + +pub use crate::kv::open_command_snapshot_bucket; +pub use connect::{Store, connect_store}; +pub use event_store::EventStore; From 952b24aaedf2ef6463ae63f689a8b9f69bdb51b4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 19 Jun 2026 02:53:27 -0400 Subject: [PATCH 09/11] fix(scheduler): make the schedules read-model projection production-ready The KV read model backs queries and must stay correct across arbitrary schedule ids, partial failures, concurrent restarts, and full replay; it previously could wedge startup, fail durable appends, or misrepresent schedule state. Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-scheduler/src/kv.rs | 18 +- .../crates/trogon-scheduler/src/mocks.rs | 74 ++- .../crates/trogon-scheduler/src/nats.rs | 18 +- .../trogon-scheduler/src/projections/mod.rs | 34 ++ .../src/projections/schedules.rs | 501 +++++++++++++----- .../trogon-scheduler/src/queries/get.rs | 10 +- .../trogon-scheduler/src/queries/list.rs | 14 +- .../src/queries/schedule_id.rs | 51 +- .../src/read_model/message.rs | 52 +- .../src/read_model/schedule.rs | 18 +- .../src/read_model/schedule_event_delivery.rs | 5 +- .../src/read_model/schedule_event_schedule.rs | 6 +- .../trogon-scheduler/src/store/event_store.rs | 18 +- .../trogon-scheduler/tests/integration.rs | 323 ++++++++++- .../trogon-scheduler/tests/schedule_unit.rs | 12 +- 15 files changed, 910 insertions(+), 244 deletions(-) diff --git a/rsworkspace/crates/trogon-scheduler/src/kv.rs b/rsworkspace/crates/trogon-scheduler/src/kv.rs index eab2e9a32..dccd43681 100644 --- a/rsworkspace/crates/trogon-scheduler/src/kv.rs +++ b/rsworkspace/crates/trogon-scheduler/src/kv.rs @@ -6,13 +6,29 @@ use trogon_nats::jetstream::{ }; use crate::error::SchedulerError; +use crate::processor::execution::reconciliation::{ScheduleKey, StreamRoutingId}; pub const SCHEDULES_BUCKET: &str = "scheduler_schedules"; pub const EVENTS_STREAM: &str = "SCHEDULER_EVENTS"; pub const EVENTS_SUBJECT_PREFIX: &str = "scheduler.schedules.events."; pub const EVENTS_SUBJECT_PATTERN: &str = "scheduler.schedules.events.>"; pub const COMMAND_SNAPSHOT_BUCKET: &str = "scheduler_command_snapshots"; -pub const SCHEDULES_CHECKPOINT_KEY: &str = "_query.schedules.last_event_sequence"; +// Versioned: the read model keys entries by a derived token (see `read_model_key`) +// rather than the raw schedule id. The version forces a one-time full rebuild on +// upgrade so the bucket is re-keyed under the new scheme; the catch-up GC sweep +// then removes any entry written under the old (raw-id) scheme. +pub const SCHEDULES_CHECKPOINT_KEY: &str = "_query.schedules.read_model.v2.last_event_sequence"; + +/// Derives the KV key for a schedule's read-model entry. +/// +/// A schedule id may be any string the command domain allows — dots, slashes, +/// `:`, `@`, non-ASCII, up to 256 chars — which is not always a valid NATS KV +/// key. Keying by a derived 32-hex token (the same scheme the execution worker +/// uses for its checkpoint) makes every schedule both storable and addressable, +/// regardless of the characters in its id. +pub(crate) fn read_model_key(schedule_id: &str) -> String { + ScheduleKey::for_stream(&StreamRoutingId::from(schedule_id)).simple() +} #[cfg(not(coverage))] pub async fn get_or_create_schedules_bucket(js: &jetstream::Context) -> Result { diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index 5fc73b99b..1f2e2ccd4 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -145,9 +145,10 @@ fn proto_schedule(schedule: &ScheduleEventSchedule) -> v1::Schedule { } .into() } - ScheduleEventSchedule::Every { every_sec } => v1::schedule::Every { + ScheduleEventSchedule::Every { every } => v1::schedule::Every { every: MessageField::some(Duration { - seconds: *every_sec as i64, + seconds: every.as_secs() as i64, + nanos: every.subsec_nanos() as i32, ..Default::default() }), } @@ -203,15 +204,16 @@ fn proto_delivery(delivery: &ScheduleEventDelivery) -> v1::Delivery { match delivery { ScheduleEventDelivery::NatsMessage { subject, - ttl_sec, + ttl, source, } => v1::Delivery { kind: Some( v1::delivery::NatsMessage { subject: subject.clone(), - ttl: ttl_sec - .map(|s| Duration { - seconds: s as i64, + ttl: ttl + .map(|d| Duration { + seconds: d.as_secs() as i64, + nanos: d.subsec_nanos() as i32, ..Default::default() }) .map(MessageField::some) @@ -244,7 +246,7 @@ fn proto_sampling_source(source: &ScheduleEventSamplingSource) -> v1::delivery:: fn proto_message(message: &MessageEnvelope) -> v1::Message { v1::Message { content: MessageField::some(trogonai_proto::content::v1alpha1::Content { - content_type: "application/json".to_string(), + content_type: message.content.content_type().to_string(), data: message.content.as_str().as_bytes().to_vec(), }), headers: message @@ -262,6 +264,9 @@ fn proto_message(message: &MessageEnvelope) -> v1::Message { fn schedule_read_model_from_proto(stream_id: &str, details: &v1::ScheduleCreated) -> Schedule { Schedule { id: stream_id.to_string(), + completed: false, + next_occurrence_at: None, + last_occurrence_at: None, status: { let is_paused = matches!( details.status.as_option().and_then(|s| s.kind.as_ref()), @@ -277,24 +282,23 @@ fn schedule_read_model_from_proto(stream_id: &str, details: &v1::ScheduleCreated .schedule .as_option() .map(schedule_from_proto) - .unwrap_or(ScheduleEventSchedule::Every { every_sec: 0 }), + .unwrap_or(ScheduleEventSchedule::Every { + every: std::time::Duration::ZERO, + }), delivery: details .delivery .as_option() .map(delivery_from_proto) .unwrap_or_else(|| ScheduleEventDelivery::NatsMessage { subject: String::new(), - ttl_sec: None, + ttl: None, source: None, }), message: details .message .as_option() .map(message_from_proto) - .unwrap_or_else(|| MessageEnvelope { - content: MessageContent::new(String::new()), - headers: MessageHeaders::default(), - }), + .unwrap_or_default(), } } @@ -312,7 +316,11 @@ fn schedule_from_proto(schedule: &v1::Schedule) -> ScheduleEventSchedule { at: inner.at.as_option().map(ts_to_dt).unwrap_or_default(), }, Some(ScheduleKind::Every(inner)) => ScheduleEventSchedule::Every { - every_sec: inner.every.as_option().map(|d| d.seconds as u64).unwrap_or(0), + every: inner + .every + .as_option() + .map(|d| std::time::Duration::new(d.seconds.max(0) as u64, d.nanos.max(0) as u32)) + .unwrap_or_default(), }, Some(ScheduleKind::Cron(inner)) => ScheduleEventSchedule::Cron { expr: inner.expr.clone(), @@ -333,7 +341,9 @@ fn schedule_from_proto(schedule: &v1::Schedule) -> ScheduleEventSchedule { rdate: inner.rdate.iter().map(ts_to_dt).collect(), exdate: inner.exdate.iter().map(ts_to_dt).collect(), }, - None => ScheduleEventSchedule::Every { every_sec: 0 }, + None => ScheduleEventSchedule::Every { + every: std::time::Duration::ZERO, + }, } } @@ -341,12 +351,15 @@ fn delivery_from_proto(delivery: &v1::Delivery) -> ScheduleEventDelivery { match delivery.kind.as_ref() { Some(DeliveryKind::NatsMessage(inner)) => ScheduleEventDelivery::NatsMessage { subject: inner.subject.clone(), - ttl_sec: inner.ttl.as_option().map(|d| d.seconds as u64), + ttl: inner + .ttl + .as_option() + .map(|d| std::time::Duration::new(d.seconds.max(0) as u64, d.nanos.max(0) as u32)), source: inner.source.as_option().map(sampling_source_from_proto), }, None => ScheduleEventDelivery::NatsMessage { subject: String::new(), - ttl_sec: None, + ttl: None, source: None, }, } @@ -362,13 +375,13 @@ fn sampling_source_from_proto(source: &v1::delivery::nats_message::Source) -> Sc } fn message_from_proto(message: &v1::Message) -> MessageEnvelope { - let content_str = message + let content = message .content .as_option() - .map(|c| String::from_utf8_lossy(&c.data).into_owned()) + .map(|c| MessageContent::new(c.content_type.clone(), String::from_utf8_lossy(&c.data).into_owned())) .unwrap_or_default(); MessageEnvelope { - content: MessageContent::new(content_str), + content, headers: MessageHeaders::from_pairs( message .headers @@ -491,10 +504,14 @@ impl StreamAppend for MockSchedulerStore { Some(ScheduleEventCase::ScheduleRemoved(_)) => { projected_schedule = None; } + Some(ScheduleEventCase::ScheduleCompleted(_)) => { + if let Some(job) = projected_schedule.as_mut() { + job.completed = true; + } + } Some( ScheduleEventCase::ScheduleOccurrenceRecorded(_) - | ScheduleEventCase::ScheduleOccurrenceScheduled(_) - | ScheduleEventCase::ScheduleCompleted(_), + | ScheduleEventCase::ScheduleOccurrenceScheduled(_), ) => {} None => { return Err(SchedulerError::event_source( @@ -589,14 +606,21 @@ mod tests { Schedule { id: id.to_string(), status: ScheduleEventStatus::Scheduled, - schedule: ScheduleEventSchedule::Every { every_sec: 30 }, + completed: false, + next_occurrence_at: None, + last_occurrence_at: None, + schedule: ScheduleEventSchedule::Every { + every: std::time::Duration::from_secs(30), + }, delivery: ScheduleEventDelivery::NatsMessage { subject: "agent.run".to_string(), - ttl_sec: None, + ttl: None, source: None, }, message: MessageEnvelope { - content: MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + // Mirrors the content type the command domain emits by default + // (`MessageContent::from_static`), proving content type round-trips. + content: MessageContent::from_static("application/octet-stream", r#"{"kind":"heartbeat"}"#), headers: MessageHeaders::default(), }, } diff --git a/rsworkspace/crates/trogon-scheduler/src/nats.rs b/rsworkspace/crates/trogon-scheduler/src/nats.rs index 40d766104..dbe499317 100644 --- a/rsworkspace/crates/trogon-scheduler/src/nats.rs +++ b/rsworkspace/crates/trogon-scheduler/src/nats.rs @@ -5,7 +5,8 @@ use crate::{ error::SchedulerError, kv::{EVENTS_STREAM, EVENTS_SUBJECT_PATTERN, EVENTS_SUBJECT_PREFIX}, }; -use async_nats::jetstream::{self}; +use async_nats::jetstream::{self, stream::RetentionPolicy}; +use std::time::Duration; pub(crate) fn event_subject(job_id: &str) -> String { format!("{EVENTS_SUBJECT_PREFIX}{job_id}") @@ -39,6 +40,21 @@ pub(crate) fn validate_events_stream(stream: &jetstream::stream::Stream) -> Resu std::io::Error::other(EVENTS_STREAM), )); } + // The read model is rebuilt by folding the full event history, so the events + // stream must never drop a message. Refuse to start against any retention that + // could evict events, rather than silently producing a corrupt read model. + if config.retention != RetentionPolicy::Limits + || config.max_age != Duration::ZERO + || config.max_messages >= 0 + || config.max_messages_per_subject >= 0 + || config.max_bytes >= 0 + { + return Err(SchedulerError::event_source( + "events stream retention may drop events; it must retain full history \ + (retention=Limits, max_age=0, unlimited max_messages/max_messages_per_subject/max_bytes)", + std::io::Error::other(EVENTS_STREAM), + )); + } Ok(()) } diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs index e62a91c65..0cab00a82 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs @@ -1,3 +1,37 @@ +//! The schedules read-model projection (a NATS KV view of current schedules, +//! folded from the schedule event stream). +//! +//! # Single active writer +//! +//! This projection must be driven by a single active instance, the same +//! invariant the execution worker requires (see `processor::execution::worker`). +//! The event log itself is safe under concurrent writers — per-subject +//! optimistic concurrency serializes appends — but the KV writes here are +//! read-modify-write without compare-and-swap, and [`catch_up_schedules_read_model`] +//! rebuilds the bucket from the event log. With two instances writing the same +//! bucket, a write can land on stale state, or a boot-time rebuild can overwrite +//! a peer's newer write. +//! +//! Enforce this by deploying the scheduler as a single active instance (a single +//! replica, a `Recreate` rollout, or leader election) — not with a +//! projection-specific lock, since the worker already gates the whole service. +//! +//! ## Safety net for the rolling-restart overlap +//! +//! A rolling deploy briefly runs two instances. The projection is built to +//! degrade gracefully, not corrupt, during that window: +//! - Catch-up early-returns when the checkpoint is current, so a booting instance +//! does not rebuild (or sweep) in steady state. +//! - The checkpoint only advances contiguously, so a racing writer can stall it +//! (forcing a harmless re-fold) but never advance it past an unprojected event. +//! - The legacy-key sweep never deletes a current derived key, so a schedule a +//! peer just created cannot be swept away. +//! - A live projection failure never fails the durable append and never advances +//! the checkpoint, so any divergence self-heals on the next clean start. +//! +//! The residual is a transient stale schedule (e.g. a status that lags by one +//! event) that resolves on the schedule's next event or the next restart. + mod schedules; pub(crate) use schedules::{catch_up_schedules_read_model, project_appended_events}; diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs index 182a742e8..a196db2de 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs @@ -8,7 +8,6 @@ use async_nats::jetstream::{ use futures::StreamExt; use trogon_decider_nats::record_stream_message; use trogon_decider_runtime::{Event, EventData, EventDecode, StreamEvent, StreamPosition}; -use trogon_nats::SubjectTokenViolation; use trogon_nats::jetstream::{JetStreamGetKeyValue, JetStreamGetStream}; use chrono::{TimeZone, Utc}; @@ -16,7 +15,10 @@ use chrono::{TimeZone, Utc}; use crate::{ DeliveryKind, ScheduleEventCase, ScheduleKind, ScheduleStatusKind, SourceKind, error::SchedulerError, - kv::{EVENTS_SUBJECT_PREFIX, SCHEDULES_CHECKPOINT_KEY, open_events_stream, open_schedules_bucket}, + kv::{ + EVENTS_SUBJECT_PATTERN, EVENTS_SUBJECT_PREFIX, SCHEDULES_CHECKPOINT_KEY, open_events_stream, + open_schedules_bucket, read_model_key, + }, read_model::{ MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSamplingSource, ScheduleEventSchedule, ScheduleEventStatus, @@ -41,7 +43,6 @@ enum ScheduleStreamState { #[derive(Debug)] enum ScheduleTransitionError { - InvalidEventId { id: String, source: SubjectTokenViolation }, MismatchedEventScheduleId { stream_id: String, schedule_id: String }, MalformedEvent { context: &'static str }, CannotAddExistingSchedule { id: String }, @@ -60,10 +61,6 @@ fn apply( state: ScheduleStreamState, event: &v1::ScheduleEvent, ) -> Result { - validate_event_schedule_id(stream_id).map_err(|source| ScheduleTransitionError::InvalidEventId { - id: stream_id.to_string(), - source, - })?; validate_event_payload_schedule_id(stream_id, event)?; match (state, &event.event) { @@ -107,14 +104,22 @@ fn apply( (ScheduleStreamState::Present(job), Some(ScheduleEventCase::ScheduleRemoved(_))) => { Ok(ScheduleStreamState::Deleted(job.id)) } - ( - ScheduleStreamState::Present(job), - Some( - ScheduleEventCase::ScheduleOccurrenceRecorded(_) - | ScheduleEventCase::ScheduleOccurrenceScheduled(_) - | ScheduleEventCase::ScheduleCompleted(_), - ), - ) => Ok(ScheduleStreamState::Present(job)), + (ScheduleStreamState::Present(mut job), Some(ScheduleEventCase::ScheduleCompleted(_))) => { + job.completed = true; + // Nothing more will fire, so there is no pending occurrence. + job.next_occurrence_at = None; + Ok(ScheduleStreamState::Present(job)) + } + (ScheduleStreamState::Present(mut job), Some(ScheduleEventCase::ScheduleOccurrenceScheduled(inner))) => { + job.next_occurrence_at = inner.occurrence_at.as_option().map(timestamp_to_datetime); + Ok(ScheduleStreamState::Present(job)) + } + (ScheduleStreamState::Present(mut job), Some(ScheduleEventCase::ScheduleOccurrenceRecorded(inner))) => { + job.last_occurrence_at = inner.occurrence_at.as_option().map(timestamp_to_datetime); + // The pending occurrence has now fired; clear it until the next is armed. + job.next_occurrence_at = None; + Ok(ScheduleStreamState::Present(job)) + } (ScheduleStreamState::Deleted(id), Some(ScheduleEventCase::ScheduleCreated(_))) => { Err(ScheduleTransitionError::CannotAddDeletedSchedule { id }) } @@ -148,10 +153,6 @@ fn validate_event_payload_schedule_id( let Some(schedule_id) = event_schedule_id(event) else { return Ok(()); }; - validate_event_schedule_id(schedule_id).map_err(|source| ScheduleTransitionError::InvalidEventId { - id: schedule_id.to_string(), - source, - })?; if schedule_id == stream_id { Ok(()) } else { @@ -197,9 +198,12 @@ fn project_created_job(event: &v1::ScheduleCreated) -> Result chron .unwrap_or_default() } +fn duration_from_proto(duration: &buffa_types::google::protobuf::Duration) -> std::time::Duration { + // Schedule intervals and TTLs are non-negative; clamp defensively so a stray + // negative component cannot panic `Duration::new`. + std::time::Duration::new(duration.seconds.max(0) as u64, duration.nanos.max(0) as u32) +} + fn project_schedule(schedule: &v1::Schedule) -> Result { match schedule.kind.as_ref() { Some(ScheduleKind::At(inner)) => { @@ -227,8 +237,8 @@ fn project_schedule(schedule: &v1::Schedule) -> Result { - let every_sec = inner.every.as_option().map(|d| d.seconds as u64).unwrap_or(0); - Ok(ScheduleEventSchedule::Every { every_sec }) + let every = inner.every.as_option().map(duration_from_proto).unwrap_or_default(); + Ok(ScheduleEventSchedule::Every { every }) } Some(ScheduleKind::Cron(inner)) => Ok(ScheduleEventSchedule::Cron { expr: inner.expr.clone(), @@ -259,7 +269,7 @@ fn project_delivery(delivery: &v1::Delivery) -> Result Ok(ScheduleEventDelivery::NatsMessage { subject: inner.subject.clone(), - ttl_sec: inner.ttl.as_option().map(|d| d.seconds as u64), + ttl: inner.ttl.as_option().map(duration_from_proto), source: inner.source.as_option().map(project_sampling_source).transpose()?, }), None => Err(ScheduleTransitionError::MalformedEvent { @@ -281,33 +291,43 @@ fn project_sampling_source( } } -fn project_message(message: &v1::Message) -> MessageEnvelope { - let content_str = message - .content - .as_option() - .map(|c| String::from_utf8_lossy(&c.data).into_owned()) - .unwrap_or_default(); - MessageEnvelope { - content: MessageContent::new(content_str), +fn project_message(message: &v1::Message) -> Result { + let content = match message.content.as_option() { + Some(content) => { + // The scheduler treats content as UTF-8 text and the executor rejects + // non-UTF-8, so reject it here too rather than silently corrupting it + // with a lossy conversion. + let body = String::from_utf8(content.data.clone()).map_err(|_| ScheduleTransitionError::MalformedEvent { + context: "message content is not valid UTF-8", + })?; + MessageContent::new(content.content_type.clone(), body) + } + None => MessageContent::default(), + }; + Ok(MessageEnvelope { + content, headers: MessageHeaders::from_pairs( message .headers .iter() .map(|header| (header.name.clone(), header.value.clone())), ), - } + }) } fn projection_change(before: &ScheduleStreamState, after: &ScheduleStreamState) -> Option { match (before, after) { - (ScheduleStreamState::Initial, ScheduleStreamState::Initial) => None, (_, ScheduleStreamState::Present(spec)) => Some(ProjectionChange::Upsert(spec.clone())), - (ScheduleStreamState::Present(spec), ScheduleStreamState::Initial | ScheduleStreamState::Deleted(_)) => { + // Emit a delete for any transition into Deleted, including from Initial. + // If a ScheduleCreated was purged from the stream but a stale KV entry + // survives, the ScheduleRemoved replayed from Initial must still clear it. + // A KV delete on an absent key is idempotent, so this is always safe. + (_, ScheduleStreamState::Deleted(id)) => Some(ProjectionChange::Delete(id.clone())), + (ScheduleStreamState::Present(spec), ScheduleStreamState::Initial) => { Some(ProjectionChange::Delete(spec.id.to_string())) } - (ScheduleStreamState::Initial, ScheduleStreamState::Deleted(_)) - | (ScheduleStreamState::Deleted(_), ScheduleStreamState::Initial) - | (ScheduleStreamState::Deleted(_), ScheduleStreamState::Deleted(_)) => None, + (ScheduleStreamState::Initial, ScheduleStreamState::Initial) + | (ScheduleStreamState::Deleted(_), ScheduleStreamState::Initial) => None, } } @@ -320,7 +340,6 @@ impl From for ScheduleStreamState { impl std::fmt::Display for ScheduleTransitionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::InvalidEventId { id, .. } => write!(f, "schedule event id '{id}' is invalid"), Self::MismatchedEventScheduleId { stream_id, schedule_id } => { write!( f, @@ -347,16 +366,7 @@ impl std::fmt::Display for ScheduleTransitionError { impl std::error::Error for ScheduleTransitionError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::InvalidEventId { source, .. } => Some(source), - Self::MismatchedEventScheduleId { .. } - | Self::MalformedEvent { .. } - | Self::CannotAddExistingSchedule { .. } - | Self::CannotAddDeletedSchedule { .. } - | Self::MissingScheduleForStateChange { .. } - | Self::DeletedScheduleForStateChange { .. } - | Self::DeletedScheduleForRemoval { .. } => None, - } + None } } @@ -381,11 +391,20 @@ where return Ok(()); } - let mut states = read_model_state_map(&bucket).await?; - let start = checkpoint.max(info.state.first_sequence.saturating_sub(1)) + 1; + // Rebuild the read model by folding the full event history into a fresh + // state. Folding from empty (rather than the live KV) keeps replay + // idempotent: re-running yields the same projection no matter how far a + // previous attempt got, and events arrive in per-schedule order so the + // state machine never sees an out-of-order transition. + // + // A single malformed/foreign/undecodable event must never wedge startup, so + // per-event anomalies are logged and skipped. Only genuine infrastructure + // failures (message read, KV write) abort the rebuild; the checkpoint is then + // left behind so the next start re-folds and self-heals. + let mut states = BTreeMap::new(); let consumer = stream - .create_consumer(event_replay_consumer_config(start)) + .create_consumer(event_replay_consumer_config(info.state.first_sequence)) .await .map_err(|source| { SchedulerError::event_source("failed to create schedules read-model catch-up consumer", source) @@ -394,6 +413,16 @@ where SchedulerError::event_source("failed to open schedules read-model catch-up stream", source) })?; + // Drain to the tail, then re-check the stream: events appended while folding + // (e.g. another instance during a rolling restart) advance the target so they + // are never stranded between catch-up's ceiling and the live path. + let mut target = info.state.last_sequence; + // A KV write failure (transient backend error, or a permanently un-writable + // key) must not wedge startup. Track it and only advance the checkpoint when + // the fold was fully clean, so a later start re-folds and repairs rather than + // declaring an incomplete rebuild complete. + let mut clean = true; + while let Some(message) = messages.next().await { let message = message.map_err(|source| { SchedulerError::event_source( @@ -402,34 +431,117 @@ where ) })?; let sequence = event_message_sequence(&message, "failed to read schedules read-model catch-up event metadata")?; - if sequence > info.state.last_sequence { - break; + if let Err(source) = fold_catch_up_message(&bucket, &mut states, &message).await { + tracing::warn!(stream_sequence = sequence, %source, "failed to write a projected schedule during catch-up; will re-fold on next start"); + clean = false; } - let reached_tail = sequence >= info.state.last_sequence; - let event = decode_recorded_delivery_message(&message)?; - let data = event.decode::().map_err(|source| { - SchedulerError::event_source( - "failed to decode schedule event during schedules read-model catch-up", - source, - ) - })?; - let Some(data) = data.into_decoded() else { - write_read_model_checkpoint(&bucket, sequence).await?; - if reached_tail { - break; + + if sequence >= target { + let fresh = stream.get_info().await.map_err(|source| { + SchedulerError::event_source("failed to re-query events stream info during catch-up", source) + })?; + if fresh.state.last_sequence > target { + target = fresh.state.last_sequence; + continue; } - continue; - }; - let stream_id = schedule_id_from_event_subject(event.stream_id())?; - if let Some(change) = apply_event_to_read_model_state(&mut states, &stream_id, &data)? { - apply_projection_change(&bucket, &change).await?; - } - write_read_model_checkpoint(&bucket, sequence).await?; - if reached_tail { break; } } + if !clean { + return Ok(()); + } + + // Remove entries written under the pre-v2 (raw schedule id) key scheme so a + // listing cannot return a stale duplicate after the upgrade. + sweep_legacy_read_model_keys(&bucket).await?; + + write_read_model_checkpoint(&bucket, target).await +} + +/// True for a current-scheme read-model key — a 32-character lowercase-hex token +/// produced by [`read_model_key`]. +fn is_derived_read_model_key(key: &str) -> bool { + key.len() == 32 && key.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Deletes legacy read-model entries (keys that are neither the checkpoint key +/// nor a current derived key). +/// +/// Intentionally conservative: it never deletes a current-scheme derived key. +/// That keeps the sweep safe even if another instance is concurrently creating a +/// schedule during a rolling restart — a brand-new derived key is left untouched +/// rather than being mistaken for an orphan and removed. Stale *derived* keys are +/// instead cleared by the fold itself (a `ScheduleRemoved` projects a delete), so +/// completeness does not depend on this sweep. +async fn sweep_legacy_read_model_keys(bucket: &kv::Store) -> Result<(), SchedulerError> { + let mut keys = bucket + .keys() + .await + .map_err(|source| SchedulerError::kv_source("failed to list schedules read-model keys for sweep", source))?; + while let Some(result) = keys.next().await { + let key = + result.map_err(|source| SchedulerError::kv_source("failed to read schedules read-model key for sweep", source))?; + if key == SCHEDULES_CHECKPOINT_KEY || is_derived_read_model_key(&key) { + continue; + } + bucket + .delete(key.clone()) + .await + .map_err(|source| SchedulerError::kv_source("failed to delete legacy read-model key", source))?; + tracing::warn!(%key, "deleted legacy schedules read-model entry during catch-up sweep"); + } + + Ok(()) +} + +/// Folds one catch-up message into `states`, writing any resulting KV change. +/// +/// Per-event anomalies (undecodable payload, foreign subject, mismatched id, an +/// invalid transition) are logged and skipped — they must not abort the rebuild. +/// Only a KV write failure is propagated, because it is transient infrastructure +/// and re-folding on the next start repairs it. +async fn fold_catch_up_message( + bucket: &kv::Store, + states: &mut BTreeMap, + message: &jetstream::Message, +) -> Result<(), SchedulerError> { + let event = match decode_recorded_delivery_message(message) { + Ok(event) => event, + Err(source) => { + tracing::warn!(%source, "skipping undecodable schedule event during read-model catch-up"); + return Ok(()); + } + }; + let decoded = match event.decode::() { + Ok(decoded) => decoded, + Err(source) => { + tracing::warn!(%source, "skipping unparseable schedule event during read-model catch-up"); + return Ok(()); + } + }; + let Some(decoded) = decoded.into_decoded() else { + // A foreign or newer-than-this-deploy event type: not part of this + // read model, skip without disturbing state. + return Ok(()); + }; + let stream_id = match schedule_id_from_event_subject(event.stream_id()) { + Ok(id) => id, + Err(source) => { + tracing::warn!(%source, "skipping schedule event with unrecognized subject during read-model catch-up"); + return Ok(()); + } + }; + let change = match apply_event_to_read_model_state(states, &stream_id, &decoded) { + Ok(change) => change, + Err(source) => { + tracing::warn!(schedule_id = %stream_id, %source, "skipping invalid schedule transition during read-model catch-up"); + return Ok(()); + } + }; + if let Some(change) = change { + apply_projection_change(bucket, &change).await?; + } Ok(()) } @@ -442,12 +554,6 @@ pub(crate) async fn project_appended_events( if events.is_empty() { return Ok(()); } - validate_event_schedule_id(job_id).map_err(|source| { - SchedulerError::invalid_schedule_spec(crate::ScheduleSpecError::InvalidId { - id: job_id.to_string(), - source, - }) - })?; let mut states = BTreeMap::new(); if let Some(job) = read_projected_schedule(bucket, job_id).await? { @@ -465,7 +571,7 @@ pub(crate) async fn project_appended_events( apply_projection_change(bucket, &change).await?; } } - maybe_advance_read_model_checkpoint(bucket, final_position.as_u64()).await + maybe_advance_read_model_checkpoint(bucket, final_position.as_u64(), events.len() as u64).await } fn decode_recorded_job_event( @@ -477,10 +583,23 @@ fn decode_recorded_job_event( } fn decode_recorded_delivery_message(message: &async_nats::jetstream::Message) -> Result { - let stream_message = - async_nats::jetstream::message::StreamMessage::try_from(message.message.clone()).map_err(|source| { - SchedulerError::event_source("failed to reconstruct stream message from event delivery", source) - })?; + // A consumer-delivered message carries its stream sequence and timestamp in + // its JetStream metadata, not in the direct-get headers that + // `StreamMessage::try_from` expects, so build the stream message from + // `info()` rather than reparsing headers that are absent here. + let info = message.info().map_err(|source| { + SchedulerError::event_source( + "failed to read schedule event delivery metadata", + std::io::Error::other(source.to_string()), + ) + })?; + let stream_message = async_nats::jetstream::message::StreamMessage { + subject: message.subject.clone(), + sequence: info.stream_sequence, + headers: message.headers.clone().unwrap_or_default(), + payload: message.payload.clone(), + time: info.published, + }; decode_recorded_job_event(stream_message) } @@ -489,6 +608,9 @@ fn event_replay_consumer_config(start_sequence: u64) -> pull::OrderedConfig { pull::OrderedConfig { deliver_policy: DeliverPolicy::ByStartSequence { start_sequence }, replay_policy: ReplayPolicy::Instant, + // Only schedule-event subjects; if the stream ever carries another + // aggregate's subjects, they must not reach this projection. + filter_subject: EVENTS_SUBJECT_PATTERN.to_string(), ..Default::default() } } @@ -500,63 +622,40 @@ fn event_message_sequence(message: &jetstream::Message, context: &'static str) - .map_err(|source| SchedulerError::event_source(context, std::io::Error::other(source.to_string()))) } -fn is_read_model_metadata_key(key: &str) -> bool { - key == SCHEDULES_CHECKPOINT_KEY -} - async fn read_projected_schedule(bucket: &kv::Store, id: &str) -> Result, SchedulerError> { - let Some(entry) = bucket - .entry(id.to_string()) + let Some(value) = bucket + .get(read_model_key(id)) .await .map_err(|source| SchedulerError::kv_source("failed to read projected schedule", source))? else { return Ok(None); }; - serde_json::from_slice(&entry.value) - .map(Some) - .map_err(SchedulerError::from) -} - -async fn read_model_state_map(bucket: &kv::Store) -> Result, SchedulerError> { - let mut keys = bucket - .keys() - .await - .map_err(|source| SchedulerError::kv_source("failed to list schedules read-model keys", source))?; - let mut states = BTreeMap::new(); - - while let Some(result) = keys.next().await { - let key = - result.map_err(|source| SchedulerError::kv_source("failed to read schedules read-model key", source))?; - if is_read_model_metadata_key(&key) { - continue; - } - if let Some(job) = read_projected_schedule(bucket, &key).await? { - states.insert(key, ScheduleStreamState::Present(job)); - } - } - - Ok(states) + serde_json::from_slice(&value).map(Some).map_err(SchedulerError::from) } async fn read_read_model_checkpoint(bucket: &kv::Store) -> Result { - let Some(entry) = bucket - .entry(SCHEDULES_CHECKPOINT_KEY.to_string()) + let Some(value) = bucket + .get(SCHEDULES_CHECKPOINT_KEY.to_string()) .await .map_err(|source| SchedulerError::kv_source("failed to read schedules read-model checkpoint", source))? else { return Ok(0); }; - String::from_utf8(entry.value.to_vec()) - .ok() - .and_then(|value| value.parse::().ok()) - .ok_or_else(|| { - SchedulerError::kv_source( - "failed to decode schedules read-model checkpoint", - std::io::Error::other(SCHEDULES_CHECKPOINT_KEY), - ) - }) + // A corrupt checkpoint value (truncated write, manual edit, non-UTF8) must + // not wedge startup. Treat it as 0 and rebuild from the beginning; catch-up + // is idempotent, so a full re-fold is always safe and self-healing. + match String::from_utf8(value.to_vec()).ok().and_then(|value| value.parse::().ok()) { + Some(sequence) => Ok(sequence), + None => { + tracing::warn!( + key = SCHEDULES_CHECKPOINT_KEY, + "schedules read-model checkpoint is unreadable; treating as 0 and rebuilding" + ); + Ok(0) + } + } } async fn write_read_model_checkpoint(bucket: &kv::Store, sequence: u64) -> Result<(), SchedulerError> { @@ -567,25 +666,35 @@ async fn write_read_model_checkpoint(bucket: &kv::Store, sequence: u64) -> Resul .map_err(|source| SchedulerError::kv_source("failed to write schedules read-model checkpoint", source)) } -async fn maybe_advance_read_model_checkpoint(bucket: &kv::Store, sequence: u64) -> Result<(), SchedulerError> { +async fn maybe_advance_read_model_checkpoint( + bucket: &kv::Store, + final_position: u64, + appended: u64, +) -> Result<(), SchedulerError> { + // An append commits `appended` events with consecutive sequences ending at + // `final_position`, so the event just before the block is at + // `final_position - appended`. Advance only when the checkpoint sits exactly + // there, keeping it a contiguous low-watermark; otherwise leave it for + // catch-up to rebuild from. Correctness never depends on this advancing — + // it only lets a restart skip the re-fold when nothing is missing. let current = read_read_model_checkpoint(bucket).await?; - if current != sequence.saturating_sub(1) { + if current != final_position.saturating_sub(appended) { return Ok(()); } - write_read_model_checkpoint(bucket, sequence).await + write_read_model_checkpoint(bucket, final_position).await } async fn apply_projection_change(kv: &kv::Store, change: &ProjectionChange) -> Result<(), SchedulerError> { match change { ProjectionChange::Upsert(job) => { let value = serde_json::to_vec(job)?; - kv.put(job.id.to_string(), value.into()) + kv.put(read_model_key(&job.id), value.into()) .await .map_err(|source| SchedulerError::kv_source("failed to store projected job state", source))?; } ProjectionChange::Delete(id) => { - kv.delete(id.clone()) + kv.delete(read_model_key(id)) .await .map_err(|source| SchedulerError::kv_source("failed to delete projected job state", source))?; } @@ -618,25 +727,24 @@ fn apply_event_to_read_model_state( } fn schedule_id_from_event_subject(subject: &str) -> Result { + // The id round-trips losslessly: the live key is the raw id and the event + // subject is exactly `EVENTS_SUBJECT_PREFIX + id`, so stripping the prefix + // recovers it verbatim (including dots/slashes/non-ASCII the domain allows). + // The projection must never reject an id that the command layer accepted and + // durably appended, or the read model would diverge from the event log. let raw_id = subject.strip_prefix(EVENTS_SUBJECT_PREFIX).ok_or_else(|| { SchedulerError::event_source( "failed to derive schedule stream id from event subject", std::io::Error::other(subject.to_string()), ) })?; - - validate_event_schedule_id(raw_id) - .map(|()| raw_id.to_string()) - .map_err(|source| { - SchedulerError::invalid_schedule_spec(crate::ScheduleSpecError::InvalidId { - id: raw_id.to_string(), - source, - }) - }) -} - -fn validate_event_schedule_id(id: &str) -> Result<(), SubjectTokenViolation> { - trogon_nats::NatsToken::new(id).map(|_| ()) + if raw_id.is_empty() { + return Err(SchedulerError::event_source( + "schedule event subject has an empty schedule id", + std::io::Error::other(subject.to_string()), + )); + } + Ok(raw_id.to_string()) } #[cfg(test)] @@ -665,14 +773,19 @@ mod tests { Schedule { id: id.to_string(), status: ScheduleEventStatus::Scheduled, - schedule: ScheduleEventSchedule::Every { every_sec: 30 }, + completed: false, + next_occurrence_at: None, + last_occurrence_at: None, + schedule: ScheduleEventSchedule::Every { + every: std::time::Duration::from_secs(30), + }, delivery: ScheduleEventDelivery::NatsMessage { subject: "agent.run".to_string(), - ttl_sec: None, + ttl: None, source: None, }, message: MessageEnvelope { - content: MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + content: MessageContent::from_static("application/json", r#"{"kind":"heartbeat"}"#), headers: MessageHeaders::default(), }, } @@ -765,6 +878,102 @@ mod tests { } } + fn completed_event(id: &str) -> v1::ScheduleEvent { + v1::ScheduleEvent { + event: Some( + v1::ScheduleCompleted { + schedule_id: id.to_string(), + last_occurrence_sequence: Some(2), + } + .into(), + ), + } + } + + fn occurrence_scheduled_event(id: &str, at: &str) -> v1::ScheduleEvent { + v1::ScheduleEvent { + event: Some( + v1::ScheduleOccurrenceScheduled { + schedule_id: id.to_string(), + occurrence_sequence: Some(1), + occurrence_at: MessageField::some(timestamp_from_str(at)), + scheduled_at: MessageField::some(timestamp_from_str(at)), + } + .into(), + ), + } + } + + fn occurrence_recorded_event(id: &str, sequence: u64, at: &str) -> v1::ScheduleEvent { + v1::ScheduleEvent { + event: Some( + v1::ScheduleOccurrenceRecorded { + schedule_id: id.to_string(), + occurrence_sequence: Some(sequence), + occurrence_at: MessageField::some(timestamp_from_str(at)), + recorded_at: MessageField::some(timestamp_from_str(at)), + } + .into(), + ), + } + } + + #[test] + fn event_projection_preserves_subsecond_every() { + let schedule = v1::Schedule { + kind: Some( + v1::schedule::Every { + every: MessageField::some(Duration { + seconds: 1, + nanos: 500_000_000, + ..Default::default() + }), + } + .into(), + ), + }; + + assert_eq!( + project_schedule(&schedule).unwrap(), + ScheduleEventSchedule::Every { + every: std::time::Duration::from_millis(1500), + } + ); + } + + #[test] + fn event_projection_tracks_next_and_last_occurrence() { + let at: DateTime = "2026-06-04T00:00:00+00:00".parse().unwrap(); + + let created = apply("backup", initial_state(), &added_event("backup")).unwrap(); + let scheduled = apply("backup", created, &occurrence_scheduled_event("backup", "2026-06-04T00:00:00+00:00")).unwrap(); + let ScheduleStreamState::Present(job) = &scheduled else { + panic!("expected present schedule"); + }; + assert_eq!(job.next_occurrence_at, Some(at)); + assert_eq!(job.last_occurrence_at, None); + + let recorded = + apply("backup", scheduled, &occurrence_recorded_event("backup", 1, "2026-06-04T00:00:00+00:00")).unwrap(); + let ScheduleStreamState::Present(job) = recorded else { + panic!("expected present schedule"); + }; + assert_eq!(job.last_occurrence_at, Some(at)); + assert_eq!(job.next_occurrence_at, None, "recording consumes the pending occurrence"); + } + + #[test] + fn event_projection_marks_completed_without_removing() { + let created = apply("backup", initial_state(), &added_event("backup")).unwrap(); + let completed = apply("backup", created, &completed_event("backup")).unwrap(); + + let ScheduleStreamState::Present(job) = completed else { + panic!("a completed schedule stays present in the read model"); + }; + assert!(job.completed, "completion must be recorded"); + assert!(!job.is_enabled(), "a completed schedule is no longer enabled"); + } + #[test] fn event_projection_replays_latest_state() { let events = [added_event("backup"), paused_event("backup"), removed_event("backup")]; diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs index bb40c9e87..688ff290a 100644 --- a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs +++ b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs @@ -1,6 +1,6 @@ use async_nats::jetstream::kv; -use crate::{error::SchedulerError, read_model::Schedule}; +use crate::{error::SchedulerError, kv::read_model_key, read_model::Schedule}; use super::ScheduleId; @@ -16,15 +16,13 @@ impl GetSchedule { } pub async fn run(store: &kv::Store, command: GetSchedule) -> Result, SchedulerError> { - let Some(entry) = store - .entry(command.id.as_str()) + let Some(value) = store + .get(read_model_key(command.id.as_str())) .await .map_err(|source| SchedulerError::kv_source("failed to read projected schedule", source))? else { return Ok(None); }; - serde_json::from_slice(&entry.value) - .map(Some) - .map_err(SchedulerError::from) + serde_json::from_slice(&value).map(Some).map_err(SchedulerError::from) } diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/list.rs b/rsworkspace/crates/trogon-scheduler/src/queries/list.rs index d5ae998ca..ebe8b9db7 100644 --- a/rsworkspace/crates/trogon-scheduler/src/queries/list.rs +++ b/rsworkspace/crates/trogon-scheduler/src/queries/list.rs @@ -20,15 +20,21 @@ pub async fn run(store: &kv::Store, _command: ListSchedules) -> Result jobs.push(job), + Err(source) => { + tracing::warn!(%key, %source, "skipping unreadable projected schedule entry during list"); + } + } } Ok(jobs) diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs b/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs index 2bb8960c3..f7ffd2aae 100644 --- a/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs +++ b/rsworkspace/crates/trogon-scheduler/src/queries/schedule_id.rs @@ -2,29 +2,29 @@ use std::fmt; use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use trogon_nats::{NatsToken, SubjectTokenViolation}; +use crate::commands::domain::{ScheduleId as DomainScheduleId, ScheduleIdError as DomainScheduleIdError}; + +/// A schedule identifier for read-model queries. +/// +/// This shares the command domain's identifier contract so any schedule that can +/// be created can also be queried: the read model derives a token-safe KV key +/// from the raw id, so ids the underlying NATS KV key syntax would reject (dots, +/// slashes, `:`, `@`, non-ASCII) are still addressable here. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ScheduleId(NatsToken); +pub struct ScheduleId(String); #[derive(Debug)] -pub struct ScheduleIdError { - raw: String, - source: SubjectTokenViolation, -} +pub struct ScheduleIdError(DomainScheduleIdError); impl ScheduleId { pub fn parse(raw: &str) -> Result { - NatsToken::new(raw) - .map(Self) - .map_err(|source| ScheduleIdError::new(raw, source)) + DomainScheduleId::parse(raw) + .map(|id| Self(id.as_str().to_string())) + .map_err(ScheduleIdError) } pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn as_token(&self) -> &NatsToken { &self.0 } } @@ -62,15 +62,6 @@ impl<'de> Deserialize<'de> for ScheduleId { } } -impl ScheduleIdError { - fn new(raw: &str, source: SubjectTokenViolation) -> Self { - Self { - raw: raw.to_string(), - source, - } - } -} - impl fmt::Display for ScheduleId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) @@ -79,13 +70,13 @@ impl fmt::Display for ScheduleId { impl fmt::Display for ScheduleIdError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "schedule id '{}' is invalid: {}", self.raw, self.source) + fmt::Display::fmt(&self.0, f) } } impl std::error::Error for ScheduleIdError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(&self.source) + Some(&self.0) } } @@ -94,11 +85,15 @@ mod tests { use super::*; #[test] - fn invalid_schedule_id_preserves_subject_token_violation_as_source() { + fn rejects_empty_schedule_id() { let error = ScheduleId::parse("").unwrap_err(); + assert!(error.to_string().contains("must not be empty")); + } - let source = std::error::Error::source(&error).unwrap(); - - assert_eq!(source.to_string(), SubjectTokenViolation::Empty.to_string()); + #[test] + fn accepts_ids_a_single_nats_token_would_reject() { + for raw in ["report.v2", "orders/created", "ns:thing", "café-nightly"] { + assert_eq!(ScheduleId::parse(raw).unwrap().as_str(), raw, "{raw:?}"); + } } } diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs index d0f5b5975..431bd2d08 100644 --- a/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/message.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum MessageHeadersError { @@ -96,8 +96,13 @@ impl<'de> Deserialize<'de> for MessageHeaders { where D: Deserializer<'de>, { + // Read back exactly what the projection wrote. The projection stores + // headers verbatim (`from_pairs`) since the command layer already + // validated them, so deserialization must not re-validate — otherwise a + // header a stricter validator would reject becomes a permanently + // unreadable KV entry. let headers = Vec::<(String, String)>::deserialize(deserializer)?; - Self::new(headers).map_err(D::Error::custom) + Ok(Self::from_pairs(headers)) } } @@ -109,39 +114,42 @@ pub struct MessageEnvelope { } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct MessageContent(String); +pub struct MessageContent { + /// The payload's media type (e.g. `application/json`), preserved from the + /// event so the read model reflects exactly what will be delivered. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub content_type: String, + /// The payload. The scheduler treats message content as UTF-8 text (the + /// executor rejects non-UTF-8), so the read model stores it as a string. + pub body: String, +} impl MessageContent { - pub fn new(content: impl Into) -> Self { - Self(content.into()) + pub fn new(content_type: impl Into, body: impl Into) -> Self { + Self { + content_type: content_type.into(), + body: body.into(), + } + } + + pub fn from_static(content_type: &'static str, body: &'static str) -> Self { + Self::new(content_type, body) } - pub fn from_static(content: &'static str) -> Self { - Self(content.to_string()) + pub fn content_type(&self) -> &str { + &self.content_type } pub fn as_str(&self) -> &str { - &self.0 + &self.body } pub fn as_slice(&self) -> &[u8] { - self.0.as_bytes() + self.body.as_bytes() } pub fn into_string(self) -> String { - self.0 - } -} - -impl From for MessageContent { - fn from(value: String) -> Self { - Self(value) - } -} - -impl From<&str> for MessageContent { - fn from(value: &str) -> Self { - Self(value.to_string()) + self.body } } diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs index 1448404ab..946ddb7b4 100644 --- a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::{MessageEnvelope, ScheduleDetails, ScheduleEventDelivery, ScheduleEventSchedule, ScheduleEventStatus}; @@ -7,6 +8,18 @@ pub struct Schedule { pub id: String, #[serde(default, rename = "state")] pub status: ScheduleEventStatus, + /// A recurring schedule that has run to exhaustion is terminal: it stays + /// visible in the read model but will never fire again. Tracked separately + /// from `status` (mirroring the command aggregate's `completed` flag) so a + /// completed schedule is not indistinguishable from an active one. + #[serde(default)] + pub completed: bool, + /// The next planned occurrence, if one is armed and pending. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_occurrence_at: Option>, + /// The most recently recorded occurrence, if any has fired. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_occurrence_at: Option>, pub schedule: ScheduleEventSchedule, pub delivery: ScheduleEventDelivery, pub message: MessageEnvelope, @@ -14,7 +27,7 @@ pub struct Schedule { impl Schedule { pub fn is_enabled(&self) -> bool { - matches!(self.status, ScheduleEventStatus::Scheduled) + !self.completed && matches!(self.status, ScheduleEventStatus::Scheduled) } } @@ -23,6 +36,9 @@ impl From<(String, ScheduleDetails)> for Schedule { Self { id, status: details.status, + completed: false, + next_occurrence_at: None, + last_occurrence_at: None, schedule: details.schedule, delivery: details.delivery, message: details.message, diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs index 3fe6d66b9..4a62ccc7b 100644 --- a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_delivery.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use serde::{Deserialize, Serialize}; use super::ScheduleEventSamplingSource; @@ -7,8 +9,9 @@ use super::ScheduleEventSamplingSource; pub enum ScheduleEventDelivery { NatsMessage { subject: String, + /// Full-precision message TTL, mirroring the schedule interval's fidelity. #[serde(default, skip_serializing_if = "Option::is_none")] - ttl_sec: Option, + ttl: Option, #[serde(default, skip_serializing_if = "Option::is_none")] source: Option, }, diff --git a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs index 7c7cd15aa..be701b4d8 100644 --- a/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs +++ b/rsworkspace/crates/trogon-scheduler/src/read_model/schedule_event_schedule.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -8,7 +10,9 @@ pub enum ScheduleEventSchedule { at: DateTime, }, Every { - every_sec: u64, + /// Full-precision interval. The executor formats sub-second intervals as + /// Go durations, so the read model must not truncate to whole seconds. + every: Duration, }, Cron { expr: String, diff --git a/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs b/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs index df710865f..bd3d6c2fc 100644 --- a/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs +++ b/rsworkspace/crates/trogon-scheduler/src/store/event_store.rs @@ -83,13 +83,27 @@ impl StreamAppend for EventStore { let projected_events = request.events.clone(); let outcome = self.inner.append_stream(request).await.map_err(SchedulerError::from)?; - project_appended_events( + // The append is the source of truth and has committed. The KV read model + // is a derived projection, so a projection failure must NOT turn a durable + // append into a caller-visible error: the checkpoint is left unadvanced and + // catch-up rebuilds the affected schedule on the next start (the rebuild is + // idempotent). Surface the failure loudly for observability instead. + if let Err(source) = project_appended_events( &self.schedules_bucket, stream_id, projected_events.as_slice(), outcome.stream_position, ) - .await?; + .await + { + tracing::error!( + schedule_id = %stream_id, + stream_position = outcome.stream_position.as_u64(), + %source, + "failed to project appended schedule events into the read model; \ + the append is durable and catch-up will repair the read model on restart" + ); + } Ok(outcome) } diff --git a/rsworkspace/crates/trogon-scheduler/tests/integration.rs b/rsworkspace/crates/trogon-scheduler/tests/integration.rs index 378ba1615..5248575b8 100644 --- a/rsworkspace/crates/trogon-scheduler/tests/integration.rs +++ b/rsworkspace/crates/trogon-scheduler/tests/integration.rs @@ -4,12 +4,13 @@ use std::time::Duration; use async_nats::Request; use async_nats::jetstream; +use chrono::{DateTime, Utc}; use trogon_decider_runtime::{CommandExecution, ReadFrom, ReadStreamRequest, StreamRead, TokioSnapshotTaskScheduler}; use trogon_nats::{NatsConfig, connect as nats_connect}; use trogon_scheduler::{ - CreateSchedule, GetScheduleCommand, PauseSchedule, RemoveSchedule, ResumeSchedule, ScheduleEventCase, - ScheduleEventSchedule, ScheduleEventStatus, ScheduleId, commands::domain as command_domain, connect_store, - get_schedule, state_v1, v1, + CreateSchedule, GetScheduleCommand, ListSchedulesCommand, PauseSchedule, RecordScheduleOccurrence, RemoveSchedule, + ResumeSchedule, ScheduleEventCase, ScheduleEventSchedule, ScheduleEventStatus, ScheduleId, ScheduleNextOccurrence, + commands::domain as command_domain, connect_store, get_schedule, list_schedules, state_v1, v1, }; fn test_url() -> String { @@ -123,6 +124,322 @@ async fn event_store_rebuilds_current_state_for_new_client() { assert_eq!(rebuilt.schedule, expected_schedule); } +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn removed_schedule_reads_back_as_absent() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + + let id = command_schedule_id("retired"); + CommandExecution::new(&store.event_store, &base_schedule("retired")) + .execute() + .await + .unwrap(); + CommandExecution::new(&store.event_store, &RemoveSchedule::new(id)) + .with_snapshot(&store.event_store) + .with_task_runtime(TokioSnapshotTaskScheduler) + .execute() + .await + .unwrap(); + + // The projection deletes the key on removal, leaving a KV tombstone. Both + // the point read and the listing must treat that tombstone as absent rather + // than failing to deserialize its empty value. + let queried = ScheduleId::parse("retired").unwrap(); + assert!( + get_schedule(&store.schedules_bucket, GetScheduleCommand::new(queried.clone())) + .await + .unwrap() + .is_none() + ); + assert!( + list_schedules(&store.schedules_bucket, ListSchedulesCommand) + .await + .unwrap() + .is_empty() + ); + + // A fresh client rebuilds the read model from the event stream and must + // reach the same absent result through the catch-up path. + let fresh = connect_store(nats).await.unwrap(); + assert!( + get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(queried)) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn catch_up_rebuilds_read_model_after_a_multi_event_append() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + + // A recurring schedule recording an occurrence appends two events at once + // (recorded + follow-up), which leaves the read-model checkpoint behind the + // stream tail. Catch-up must still rebuild the read model on the next start. + let id = command_schedule_id("recurring"); + let mut create = base_schedule("recurring"); + create.schedule = command_domain::Schedule::rrule("2026-06-03T00:00:00Z", "FREQ=DAILY;COUNT=5", None).unwrap(); + CommandExecution::new(&store.event_store, &create).execute().await.unwrap(); + + let now = DateTime::parse_from_rfc3339("2026-06-04T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + CommandExecution::new(&store.event_store, &ScheduleNextOccurrence::new(id.clone(), now)) + .execute() + .await + .unwrap(); + + let armed = store + .event_store + .read_stream(ReadStreamRequest { + stream_id: "recurring", + from: ReadFrom::Beginning, + }) + .await + .unwrap() + .events + .iter() + .filter_map(|event| event.decode::().unwrap().into_decoded()) + .find_map(|event| match event.event { + Some(ScheduleEventCase::ScheduleOccurrenceScheduled(scheduled)) => { + Some(trogonai_proto::convert::datetime_from_timestamp(scheduled.occurrence_at.as_option().unwrap()).unwrap()) + } + _ => None, + }) + .expect("an occurrence was armed"); + + CommandExecution::new( + &store.event_store, + &RecordScheduleOccurrence::new(id.clone(), armed, now), + ) + .execute() + .await + .unwrap(); + + // A second schedule created after the stall: its ScheduleCreated now lives in + // the replay window while it is already present in the KV. + CommandExecution::new(&store.event_store, &base_schedule("second")) + .execute() + .await + .unwrap(); + + // The fresh client must rebuild from the event stream without failing and + // surface both schedules. + let fresh = connect_store(nats).await.unwrap(); + assert!( + get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("recurring").unwrap())) + .await + .unwrap() + .is_some() + ); + assert!( + get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("second").unwrap())) + .await + .unwrap() + .is_some() + ); +} + +async fn purge_schedules_bucket(js: &jetstream::Context) { + let kv = js.get_key_value(trogon_scheduler::kv::SCHEDULES_BUCKET).await.unwrap(); + let mut keys = kv.keys().await.unwrap(); + while let Some(result) = futures::StreamExt::next(&mut keys).await { + let _ = kv.purge(result.unwrap()).await; + } +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn projection_folds_permissive_schedule_ids_through_live_and_catch_up() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + + // IDs the command domain accepts but a single NATS token would reject — + // including ':' and non-ASCII that are not valid raw KV keys. The read model + // keys by a derived token, so all of them must be addressable by get, present + // in list, and survive a catch-up rebuild. + let ids = ["report.v2", "orders/created", "ns:thing", "café-nightly"]; + for id in ids { + CommandExecution::new(&store.event_store, &base_schedule(id)) + .execute() + .await + .unwrap(); + assert!( + get_schedule(&store.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse(id).unwrap())) + .await + .unwrap() + .is_some(), + "get could not address {id}" + ); + } + + let live: Vec = list_schedules(&store.schedules_bucket, ListSchedulesCommand) + .await + .unwrap() + .into_iter() + .map(|schedule| schedule.id) + .collect(); + assert_eq!(live.len(), ids.len(), "unexpected live listing: {live:?}"); + for id in ids { + assert!(live.contains(&id.to_string()), "live projection missing {id}"); + } + + // Drop the KV read model so a fresh client must re-fold the events. + purge_schedules_bucket(&js).await; + let fresh = connect_store(nats).await.unwrap(); + let rebuilt: Vec = list_schedules(&fresh.schedules_bucket, ListSchedulesCommand) + .await + .unwrap() + .into_iter() + .map(|schedule| schedule.id) + .collect(); + assert_eq!(rebuilt.len(), ids.len(), "unexpected rebuilt listing: {rebuilt:?}"); + for id in ids { + assert!(rebuilt.contains(&id.to_string()), "catch-up rebuild missing {id}"); + assert!( + get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse(id).unwrap())) + .await + .unwrap() + .is_some(), + "get could not address {id} after rebuild" + ); + } +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn completed_recurring_schedule_is_marked_completed_in_read_model() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + + // A single-occurrence recurrence whose only occurrence is already in the past + // exhausts the moment it is armed: arming emits ScheduleCompleted. + let id = command_schedule_id("finite"); + let mut create = base_schedule("finite"); + create.schedule = command_domain::Schedule::rrule("2020-01-01T00:00:00Z", "FREQ=DAILY;COUNT=1", None).unwrap(); + CommandExecution::new(&store.event_store, &create).execute().await.unwrap(); + + let now = DateTime::parse_from_rfc3339("2026-06-19T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + CommandExecution::new(&store.event_store, &ScheduleNextOccurrence::new(id.clone(), now)) + .execute() + .await + .unwrap(); + + let completed_event = store + .event_store + .read_stream(ReadStreamRequest { + stream_id: "finite", + from: ReadFrom::Beginning, + }) + .await + .unwrap() + .events + .iter() + .filter_map(|event| event.decode::().unwrap().into_decoded()) + .any(|event| matches!(event.event, Some(ScheduleEventCase::ScheduleCompleted(_)))); + assert!(completed_event, "arming an exhausted recurrence emits ScheduleCompleted"); + + let live = get_schedule(&store.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("finite").unwrap())) + .await + .unwrap() + .expect("schedule still present after completion"); + assert!(live.completed, "completed recurring schedule must be marked completed"); + assert!(!live.is_enabled(), "a completed schedule must not be enabled"); + + // The completion survives a catch-up rebuild. + purge_schedules_bucket(&js).await; + let fresh = connect_store(nats).await.unwrap(); + let rebuilt = get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("finite").unwrap())) + .await + .unwrap() + .expect("schedule present after rebuild"); + assert!(rebuilt.completed, "completion must survive a catch-up rebuild"); +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn catch_up_sweep_removes_legacy_keys_but_keeps_derived_keys() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + CommandExecution::new(&store.event_store, &base_schedule("alpha")) + .execute() + .await + .unwrap(); + + let kv = js.get_key_value(trogon_scheduler::kv::SCHEDULES_BUCKET).await.unwrap(); + // Reuse a real projected value so both injected entries deserialize cleanly. + let value = { + let mut keys = kv.keys().await.unwrap(); + let mut found = None; + while let Some(result) = futures::StreamExt::next(&mut keys).await { + let key = result.unwrap(); + if key != trogon_scheduler::kv::SCHEDULES_CHECKPOINT_KEY { + found = kv.get(&key).await.unwrap(); + break; + } + } + found.expect("alpha was projected") + }; + // A pre-v2 raw-id key (must be swept) and a derived-format key that the fold + // does not know about — standing in for a schedule a peer created during a + // rolling restart (must be kept, never mistaken for an orphan). + let legacy_key = "legacy.raw.id".to_string(); + let peer_derived_key = "0123456789abcdef0123456789abcdef".to_string(); + kv.put(legacy_key.clone(), value.clone()).await.unwrap(); + kv.put(peer_derived_key.clone(), value.clone()).await.unwrap(); + // Force a rebuild (and therefore the sweep) on the next start. + let _ = kv.purge(trogon_scheduler::kv::SCHEDULES_CHECKPOINT_KEY.to_string()).await; + + let _fresh = connect_store(nats).await.unwrap(); + + assert!(kv.get(&legacy_key).await.unwrap().is_none(), "legacy raw-id key must be swept"); + assert!( + kv.get(&peer_derived_key).await.unwrap().is_some(), + "a derived-format key must never be swept (could be a peer's concurrent create)" + ); +} + +#[tokio::test] +#[ignore = "requires NATS test broker"] +async fn catch_up_self_heals_from_a_corrupt_checkpoint() { + let (nats, js) = connect_js().await; + reset_state(&js).await; + let store = connect_store(nats.clone()).await.unwrap(); + CommandExecution::new(&store.event_store, &base_schedule("durable")) + .execute() + .await + .unwrap(); + + // Corrupt the checkpoint value: a non-numeric checkpoint must not wedge startup. + let kv = js.get_key_value(trogon_scheduler::kv::SCHEDULES_BUCKET).await.unwrap(); + kv.put( + trogon_scheduler::kv::SCHEDULES_CHECKPOINT_KEY.to_string(), + "not-a-number".into(), + ) + .await + .unwrap(); + purge_schedules_bucket(&js).await; + + // A fresh client treats the corrupt checkpoint as 0 and rebuilds. + let fresh = connect_store(nats).await.unwrap(); + assert!( + get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("durable").unwrap())) + .await + .unwrap() + .is_some() + ); +} + #[tokio::test] #[ignore = "requires NATS test broker"] async fn commands_execute_full_lifecycle_against_event_store() { diff --git a/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs b/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs index 1709aa12c..a0758fcf4 100644 --- a/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs +++ b/rsworkspace/crates/trogon-scheduler/tests/schedule_unit.rs @@ -19,14 +19,20 @@ fn expected_schedule(id: &str) -> Schedule { Schedule { id: id.to_string(), status: ScheduleEventStatus::Scheduled, - schedule: ScheduleEventSchedule::Every { every_sec: 30 }, + completed: false, + next_occurrence_at: None, + last_occurrence_at: None, + schedule: ScheduleEventSchedule::Every { + every: std::time::Duration::from_secs(30), + }, delivery: ScheduleEventDelivery::NatsMessage { subject: "agent.run".to_string(), - ttl_sec: None, + ttl: None, source: None, }, message: MessageEnvelope { - content: MessageContent::from_static(r#"{"kind":"heartbeat"}"#), + // Matches the command domain's default content type (octet-stream). + content: MessageContent::from_static("application/octet-stream", r#"{"kind":"heartbeat"}"#), headers: MessageHeaders::default(), }, } From 22281f406ece72ed99b9a204d1923f2d2b5633b7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 19 Jun 2026 04:28:16 -0400 Subject: [PATCH 10/11] refactor(scheduler): store the schedules read model as a projections.v1 protobuf view The KV value is a contract that must evolve safely; encoding it as a versioned protobuf message lets its shape change under protobuf's field rules and keeps the read model consistent with the protobuf event log, rather than an ad-hoc JSON shape. Signed-off-by: Yordis Prieto --- .../projections/v1/schedule_view.proto | 36 + .../crates/trogon-scheduler/src/lib.rs | 4 +- .../crates/trogon-scheduler/src/mocks.rs | 12 +- .../trogon-scheduler/src/projections/mod.rs | 2 +- .../src/projections/schedules.rs | 299 ++++++- .../trogon-scheduler/src/queries/get.rs | 4 +- .../trogon-scheduler/src/queries/list.rs | 6 +- .../trogon-scheduler/tests/integration.rs | 107 ++- .../crates/trogonai-proto/src/gen/mod.rs | 29 + ....scheduler.schedules.projections.v1.mod.rs | 33 + ...les.projections.v1.schedule_view.__view.rs | 747 ++++++++++++++++++ ....schedules.projections.v1.schedule_view.rs | 406 ++++++++++ .../src/scheduler/schedules/mod.rs | 4 + 13 files changed, 1624 insertions(+), 65 deletions(-) create mode 100644 proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto create mode 100644 rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs create mode 100644 rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs create mode 100644 rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs diff --git a/proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto b/proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto new file mode 100644 index 000000000..9290b99ab --- /dev/null +++ b/proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto @@ -0,0 +1,36 @@ +edition = "2024"; + +package trogonai.scheduler.schedules.projections.v1; + +import "google/protobuf/timestamp.proto"; +import "trogonai/scheduler/schedules/v1/delivery.proto"; +import "trogonai/scheduler/schedules/v1/message.proto"; +import "trogonai/scheduler/schedules/v1/schedule.proto"; +import "trogonai/scheduler/schedules/v1/schedule_status.proto"; + +// ScheduleView is the read-model projection of a schedule's current state, +// folded from the schedule event stream and stored as the value of each entry +// in the schedules KV bucket. +// +// It is a derived, rebuildable view (not an event), serialized as protobuf so +// its shape can evolve under protobuf's field rules rather than an ad-hoc JSON +// contract. +message ScheduleView { + // Stable schedule id; also the schedule stream id. + string schedule_id = 1 [features.field_presence = LEGACY_REQUIRED]; + // Current lifecycle status (scheduled or paused). + trogonai.scheduler.schedules.v1.ScheduleStatus status = 2 [features.field_presence = LEGACY_REQUIRED]; + // True once a recurring schedule has run to exhaustion: it stays visible but + // will never fire again. + bool completed = 3; + // The next planned occurrence, if one is armed and pending. + google.protobuf.Timestamp next_occurrence_at = 4; + // The most recently recorded occurrence, if any has fired. + google.protobuf.Timestamp last_occurrence_at = 5; + // The schedule definition recorded at creation. + trogonai.scheduler.schedules.v1.Schedule schedule = 6 [features.field_presence = LEGACY_REQUIRED]; + // The delivery definition recorded at creation. + trogonai.scheduler.schedules.v1.Delivery delivery = 7 [features.field_presence = LEGACY_REQUIRED]; + // The static message payload and headers recorded at creation. + trogonai.scheduler.schedules.v1.Message message = 8 [features.field_presence = LEGACY_REQUIRED]; +} diff --git a/rsworkspace/crates/trogon-scheduler/src/lib.rs b/rsworkspace/crates/trogon-scheduler/src/lib.rs index a6d592622..acea76e45 100644 --- a/rsworkspace/crates/trogon-scheduler/src/lib.rs +++ b/rsworkspace/crates/trogon-scheduler/src/lib.rs @@ -38,6 +38,6 @@ pub use read_model::{ pub use store::{Store, connect_store, open_command_snapshot_bucket}; pub use trogon_decider_runtime::{CommandError, CommandResult, ExecutionResult, StreamWritePrecondition}; pub use trogonai_proto::scheduler::schedules::{ - DeliveryKind, ScheduleEventCase, ScheduleEventPayloadError, ScheduleKind, ScheduleStatusKind, SourceKind, state_v1, - v1, + DeliveryKind, ScheduleEventCase, ScheduleEventPayloadError, ScheduleKind, ScheduleStatusKind, SourceKind, + projections_v1, state_v1, v1, }; diff --git a/rsworkspace/crates/trogon-scheduler/src/mocks.rs b/rsworkspace/crates/trogon-scheduler/src/mocks.rs index 1f2e2ccd4..310d9df71 100644 --- a/rsworkspace/crates/trogon-scheduler/src/mocks.rs +++ b/rsworkspace/crates/trogon-scheduler/src/mocks.rs @@ -202,11 +202,7 @@ fn proto_schedule(schedule: &ScheduleEventSchedule) -> v1::Schedule { fn proto_delivery(delivery: &ScheduleEventDelivery) -> v1::Delivery { use buffa_types::google::protobuf::Duration; match delivery { - ScheduleEventDelivery::NatsMessage { - subject, - ttl, - source, - } => v1::Delivery { + ScheduleEventDelivery::NatsMessage { subject, ttl, source } => v1::Delivery { kind: Some( v1::delivery::NatsMessage { subject: subject.clone(), @@ -294,11 +290,7 @@ fn schedule_read_model_from_proto(stream_id: &str, details: &v1::ScheduleCreated ttl: None, source: None, }), - message: details - .message - .as_option() - .map(message_from_proto) - .unwrap_or_default(), + message: details.message.as_option().map(message_from_proto).unwrap_or_default(), } } diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs index 0cab00a82..ffdcedc82 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs @@ -34,4 +34,4 @@ mod schedules; -pub(crate) use schedules::{catch_up_schedules_read_model, project_appended_events}; +pub(crate) use schedules::{catch_up_schedules_read_model, decode_schedule_view, project_appended_events}; diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs index a196db2de..bdfee8987 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs @@ -19,6 +19,7 @@ use crate::{ EVENTS_SUBJECT_PATTERN, EVENTS_SUBJECT_PREFIX, SCHEDULES_CHECKPOINT_KEY, open_events_stream, open_schedules_bucket, read_model_key, }, + projections_v1, read_model::{ MessageContent, MessageEnvelope, MessageHeaders, Schedule, ScheduleEventDelivery, ScheduleEventSamplingSource, ScheduleEventSchedule, ScheduleEventStatus, @@ -297,9 +298,10 @@ fn project_message(message: &v1::Message) -> Result MessageContent::default(), @@ -315,6 +317,208 @@ fn project_message(message: &v1::Message) -> Result Vec { + buffa::Message::encode_to_vec(&schedule_view_from_read_model(job)) +} + +/// Decodes a `ScheduleView` protobuf KV value back into a read-model schedule. +pub(crate) fn decode_schedule_view(value: &[u8]) -> Result { + let view = ::decode_from_slice(value).map_err(|source| { + SchedulerError::kv_source( + "failed to decode projected schedule view", + std::io::Error::other(source.to_string()), + ) + })?; + read_model_from_schedule_view(&view) +} + +fn read_model_from_schedule_view(view: &projections_v1::ScheduleView) -> Result { + let decode_error = |context: &'static str| { + SchedulerError::kv_source("projected schedule view is malformed", std::io::Error::other(context)) + }; + let schedule = view + .schedule + .as_option() + .ok_or_else(|| decode_error("missing schedule"))?; + let delivery = view + .delivery + .as_option() + .ok_or_else(|| decode_error("missing delivery"))?; + let message = view + .message + .as_option() + .ok_or_else(|| decode_error("missing message"))?; + let to_read_model = |source: ScheduleTransitionError| { + SchedulerError::event_source("failed to project stored schedule view", source) + }; + + Ok(Schedule { + id: view.schedule_id.clone(), + status: project_status(view.status.as_option()), + completed: view.completed.unwrap_or(false), + next_occurrence_at: view.next_occurrence_at.as_option().map(timestamp_to_datetime), + last_occurrence_at: view.last_occurrence_at.as_option().map(timestamp_to_datetime), + schedule: project_schedule(schedule).map_err(to_read_model)?, + delivery: project_delivery(delivery).map_err(to_read_model)?, + message: project_message(message).map_err(to_read_model)?, + }) +} + +fn schedule_view_from_read_model(job: &Schedule) -> projections_v1::ScheduleView { + use buffa::MessageField; + projections_v1::ScheduleView { + schedule_id: job.id.clone(), + status: MessageField::some(status_to_proto(job.status)), + completed: Some(job.completed), + next_occurrence_at: optional_timestamp(job.next_occurrence_at.as_ref()), + last_occurrence_at: optional_timestamp(job.last_occurrence_at.as_ref()), + schedule: MessageField::some(schedule_to_proto(&job.schedule)), + delivery: MessageField::some(delivery_to_proto(&job.delivery)), + message: MessageField::some(message_to_proto(&job.message)), + } +} + +fn optional_timestamp( + value: Option<&chrono::DateTime>, +) -> buffa::MessageField { + value + .map(datetime_to_proto) + .map(buffa::MessageField::some) + .unwrap_or_else(buffa::MessageField::none) +} + +fn datetime_to_proto(value: &chrono::DateTime) -> buffa_types::google::protobuf::Timestamp { + buffa_types::google::protobuf::Timestamp { + seconds: value.timestamp(), + nanos: value.timestamp_subsec_nanos() as i32, + ..Default::default() + } +} + +fn duration_to_proto(duration: std::time::Duration) -> buffa_types::google::protobuf::Duration { + buffa_types::google::protobuf::Duration { + seconds: duration.as_secs() as i64, + nanos: duration.subsec_nanos() as i32, + ..Default::default() + } +} + +fn timezone_to_proto(timezone: Option<&String>) -> buffa::MessageField { + timezone + .map(|id| id.as_str()) + .filter(|id| !id.is_empty()) + .map(|id| trogonai_proto::google::r#type::TimeZone { + id: id.to_string(), + ..Default::default() + }) + .map(buffa::MessageField::some) + .unwrap_or_else(buffa::MessageField::none) +} + +fn status_to_proto(status: ScheduleEventStatus) -> v1::ScheduleStatus { + v1::ScheduleStatus { + kind: Some(match status { + ScheduleEventStatus::Scheduled => v1::schedule_status::Scheduled {}.into(), + ScheduleEventStatus::Paused => v1::schedule_status::Paused {}.into(), + }), + } +} + +fn schedule_to_proto(schedule: &ScheduleEventSchedule) -> v1::Schedule { + use buffa::MessageField; + let kind = match schedule { + ScheduleEventSchedule::At { at } => v1::schedule::At { + at: MessageField::some(datetime_to_proto(at)), + } + .into(), + ScheduleEventSchedule::Every { every } => v1::schedule::Every { + every: MessageField::some(duration_to_proto(*every)), + } + .into(), + ScheduleEventSchedule::Cron { expr, timezone } => v1::schedule::Cron { + expr: expr.clone(), + timezone: timezone_to_proto(timezone.as_ref()), + } + .into(), + ScheduleEventSchedule::RRule { + dtstart, + rrule, + timezone, + rdate, + exdate, + } => v1::schedule::RRule { + dtstart: MessageField::some(datetime_to_proto(dtstart)), + rrule: rrule.clone(), + timezone: timezone_to_proto(timezone.as_ref()), + rdate: rdate.iter().map(datetime_to_proto).collect(), + exdate: exdate.iter().map(datetime_to_proto).collect(), + } + .into(), + }; + v1::Schedule { kind: Some(kind) } +} + +fn delivery_to_proto(delivery: &ScheduleEventDelivery) -> v1::Delivery { + use buffa::MessageField; + match delivery { + ScheduleEventDelivery::NatsMessage { subject, ttl, source } => v1::Delivery { + kind: Some( + v1::delivery::NatsMessage { + subject: subject.clone(), + ttl: ttl + .map(duration_to_proto) + .map(MessageField::some) + .unwrap_or_else(MessageField::none), + source: source + .as_ref() + .map(sampling_source_to_proto) + .map(MessageField::some) + .unwrap_or_else(MessageField::none), + } + .into(), + ), + }, + } +} + +fn sampling_source_to_proto(source: &ScheduleEventSamplingSource) -> v1::delivery::nats_message::Source { + match source { + ScheduleEventSamplingSource::LatestFromSubject { subject } => v1::delivery::nats_message::Source { + kind: Some( + v1::delivery::nats_message::LatestFromSubject { + subject: subject.clone(), + } + .into(), + ), + }, + } +} + +fn message_to_proto(message: &MessageEnvelope) -> v1::Message { + use buffa::MessageField; + v1::Message { + content: MessageField::some(trogonai_proto::content::v1alpha1::Content { + content_type: message.content.content_type().to_string(), + data: message.content.as_slice().to_vec(), + }), + headers: message + .headers + .as_slice() + .iter() + .map(|(name, value)| v1::Header { + name: name.clone(), + value: value.clone(), + }) + .collect(), + } +} + fn projection_change(before: &ScheduleStreamState, after: &ScheduleStreamState) -> Option { match (before, after) { (_, ScheduleStreamState::Present(spec)) => Some(ProjectionChange::Upsert(spec.clone())), @@ -480,8 +684,8 @@ async fn sweep_legacy_read_model_keys(bucket: &kv::Store) -> Result<(), Schedule .await .map_err(|source| SchedulerError::kv_source("failed to list schedules read-model keys for sweep", source))?; while let Some(result) = keys.next().await { - let key = - result.map_err(|source| SchedulerError::kv_source("failed to read schedules read-model key for sweep", source))?; + let key = result + .map_err(|source| SchedulerError::kv_source("failed to read schedules read-model key for sweep", source))?; if key == SCHEDULES_CHECKPOINT_KEY || is_derived_read_model_key(&key) { continue; } @@ -631,7 +835,7 @@ async fn read_projected_schedule(bucket: &kv::Store, id: &str) -> Result Result { @@ -646,7 +850,10 @@ async fn read_read_model_checkpoint(bucket: &kv::Store) -> Result().ok()) { + match String::from_utf8(value.to_vec()) + .ok() + .and_then(|value| value.parse::().ok()) + { Some(sequence) => Ok(sequence), None => { tracing::warn!( @@ -688,7 +895,7 @@ async fn maybe_advance_read_model_checkpoint( async fn apply_projection_change(kv: &kv::Store, change: &ProjectionChange) -> Result<(), SchedulerError> { match change { ProjectionChange::Upsert(job) => { - let value = serde_json::to_vec(job)?; + let value = encode_schedule_view(job); kv.put(read_model_key(&job.id), value.into()) .await .map_err(|source| SchedulerError::kv_source("failed to store projected job state", source))?; @@ -946,20 +1153,88 @@ mod tests { let at: DateTime = "2026-06-04T00:00:00+00:00".parse().unwrap(); let created = apply("backup", initial_state(), &added_event("backup")).unwrap(); - let scheduled = apply("backup", created, &occurrence_scheduled_event("backup", "2026-06-04T00:00:00+00:00")).unwrap(); + let scheduled = apply( + "backup", + created, + &occurrence_scheduled_event("backup", "2026-06-04T00:00:00+00:00"), + ) + .unwrap(); let ScheduleStreamState::Present(job) = &scheduled else { panic!("expected present schedule"); }; assert_eq!(job.next_occurrence_at, Some(at)); assert_eq!(job.last_occurrence_at, None); - let recorded = - apply("backup", scheduled, &occurrence_recorded_event("backup", 1, "2026-06-04T00:00:00+00:00")).unwrap(); + let recorded = apply( + "backup", + scheduled, + &occurrence_recorded_event("backup", 1, "2026-06-04T00:00:00+00:00"), + ) + .unwrap(); let ScheduleStreamState::Present(job) = recorded else { panic!("expected present schedule"); }; assert_eq!(job.last_occurrence_at, Some(at)); - assert_eq!(job.next_occurrence_at, None, "recording consumes the pending occurrence"); + assert_eq!( + job.next_occurrence_at, None, + "recording consumes the pending occurrence" + ); + } + + #[test] + fn schedule_view_codec_round_trips_every_field() { + let dt = |s: &str| -> DateTime { s.parse().unwrap() }; + let cases = [ + Schedule { + id: "orders/created".to_string(), + status: ScheduleEventStatus::Paused, + completed: true, + next_occurrence_at: Some(dt("2026-06-04T00:00:00+00:00")), + last_occurrence_at: Some(dt("2026-06-03T00:00:00+00:00")), + schedule: ScheduleEventSchedule::Every { + every: std::time::Duration::from_millis(1500), + }, + delivery: ScheduleEventDelivery::NatsMessage { + subject: "agent.run".to_string(), + ttl: Some(std::time::Duration::from_secs(30)), + source: Some(ScheduleEventSamplingSource::LatestFromSubject { + subject: "sensors.latest".to_string(), + }), + }, + message: MessageEnvelope { + content: MessageContent::new("text/plain", "hello"), + headers: MessageHeaders::from_pairs([("x-kind".to_string(), "heartbeat".to_string())]), + }, + }, + Schedule { + id: "café-nightly".to_string(), + status: ScheduleEventStatus::Scheduled, + completed: false, + next_occurrence_at: None, + last_occurrence_at: None, + schedule: ScheduleEventSchedule::RRule { + dtstart: dt("2026-05-24T09:00:00+00:00"), + rrule: "FREQ=WEEKLY;BYDAY=MO".to_string(), + timezone: Some("UTC".to_string()), + rdate: vec![dt("2026-05-26T09:00:00+00:00")], + exdate: vec![dt("2026-06-01T09:00:00+00:00")], + }, + delivery: ScheduleEventDelivery::NatsMessage { + subject: "agent.run".to_string(), + ttl: None, + source: None, + }, + message: MessageEnvelope { + content: MessageContent::new("application/json", r#"{"k":1}"#), + headers: MessageHeaders::default(), + }, + }, + ]; + + for job in cases { + let round_tripped = decode_schedule_view(&encode_schedule_view(&job)).unwrap(); + assert_eq!(round_tripped, job); + } } #[test] diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs index 688ff290a..d18174b8a 100644 --- a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs +++ b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs @@ -1,6 +1,6 @@ use async_nats::jetstream::kv; -use crate::{error::SchedulerError, kv::read_model_key, read_model::Schedule}; +use crate::{error::SchedulerError, kv::read_model_key, projections::decode_schedule_view, read_model::Schedule}; use super::ScheduleId; @@ -24,5 +24,5 @@ pub async fn run(store: &kv::Store, command: GetSchedule) -> Result Result jobs.push(job), Err(source) => { tracing::warn!(%key, %source, "skipping unreadable projected schedule entry during list"); diff --git a/rsworkspace/crates/trogon-scheduler/tests/integration.rs b/rsworkspace/crates/trogon-scheduler/tests/integration.rs index 5248575b8..010b1c71d 100644 --- a/rsworkspace/crates/trogon-scheduler/tests/integration.rs +++ b/rsworkspace/crates/trogon-scheduler/tests/integration.rs @@ -184,7 +184,10 @@ async fn catch_up_rebuilds_read_model_after_a_multi_event_append() { let id = command_schedule_id("recurring"); let mut create = base_schedule("recurring"); create.schedule = command_domain::Schedule::rrule("2026-06-03T00:00:00Z", "FREQ=DAILY;COUNT=5", None).unwrap(); - CommandExecution::new(&store.event_store, &create).execute().await.unwrap(); + CommandExecution::new(&store.event_store, &create) + .execute() + .await + .unwrap(); let now = DateTime::parse_from_rfc3339("2026-06-04T00:00:00Z") .unwrap() @@ -206,9 +209,9 @@ async fn catch_up_rebuilds_read_model_after_a_multi_event_append() { .iter() .filter_map(|event| event.decode::().unwrap().into_decoded()) .find_map(|event| match event.event { - Some(ScheduleEventCase::ScheduleOccurrenceScheduled(scheduled)) => { - Some(trogonai_proto::convert::datetime_from_timestamp(scheduled.occurrence_at.as_option().unwrap()).unwrap()) - } + Some(ScheduleEventCase::ScheduleOccurrenceScheduled(scheduled)) => Some( + trogonai_proto::convert::datetime_from_timestamp(scheduled.occurrence_at.as_option().unwrap()).unwrap(), + ), _ => None, }) .expect("an occurrence was armed"); @@ -232,16 +235,22 @@ async fn catch_up_rebuilds_read_model_after_a_multi_event_append() { // surface both schedules. let fresh = connect_store(nats).await.unwrap(); assert!( - get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("recurring").unwrap())) - .await - .unwrap() - .is_some() + get_schedule( + &fresh.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse("recurring").unwrap()) + ) + .await + .unwrap() + .is_some() ); assert!( - get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("second").unwrap())) - .await - .unwrap() - .is_some() + get_schedule( + &fresh.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse("second").unwrap()) + ) + .await + .unwrap() + .is_some() ); } @@ -271,10 +280,13 @@ async fn projection_folds_permissive_schedule_ids_through_live_and_catch_up() { .await .unwrap(); assert!( - get_schedule(&store.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse(id).unwrap())) - .await - .unwrap() - .is_some(), + get_schedule( + &store.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse(id).unwrap()) + ) + .await + .unwrap() + .is_some(), "get could not address {id}" ); } @@ -303,10 +315,13 @@ async fn projection_folds_permissive_schedule_ids_through_live_and_catch_up() { for id in ids { assert!(rebuilt.contains(&id.to_string()), "catch-up rebuild missing {id}"); assert!( - get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse(id).unwrap())) - .await - .unwrap() - .is_some(), + get_schedule( + &fresh.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse(id).unwrap()) + ) + .await + .unwrap() + .is_some(), "get could not address {id} after rebuild" ); } @@ -324,7 +339,10 @@ async fn completed_recurring_schedule_is_marked_completed_in_read_model() { let id = command_schedule_id("finite"); let mut create = base_schedule("finite"); create.schedule = command_domain::Schedule::rrule("2020-01-01T00:00:00Z", "FREQ=DAILY;COUNT=1", None).unwrap(); - CommandExecution::new(&store.event_store, &create).execute().await.unwrap(); + CommandExecution::new(&store.event_store, &create) + .execute() + .await + .unwrap(); let now = DateTime::parse_from_rfc3339("2026-06-19T00:00:00Z") .unwrap() @@ -346,22 +364,31 @@ async fn completed_recurring_schedule_is_marked_completed_in_read_model() { .iter() .filter_map(|event| event.decode::().unwrap().into_decoded()) .any(|event| matches!(event.event, Some(ScheduleEventCase::ScheduleCompleted(_)))); - assert!(completed_event, "arming an exhausted recurrence emits ScheduleCompleted"); + assert!( + completed_event, + "arming an exhausted recurrence emits ScheduleCompleted" + ); - let live = get_schedule(&store.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("finite").unwrap())) - .await - .unwrap() - .expect("schedule still present after completion"); + let live = get_schedule( + &store.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse("finite").unwrap()), + ) + .await + .unwrap() + .expect("schedule still present after completion"); assert!(live.completed, "completed recurring schedule must be marked completed"); assert!(!live.is_enabled(), "a completed schedule must not be enabled"); // The completion survives a catch-up rebuild. purge_schedules_bucket(&js).await; let fresh = connect_store(nats).await.unwrap(); - let rebuilt = get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("finite").unwrap())) - .await - .unwrap() - .expect("schedule present after rebuild"); + let rebuilt = get_schedule( + &fresh.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse("finite").unwrap()), + ) + .await + .unwrap() + .expect("schedule present after rebuild"); assert!(rebuilt.completed, "completion must survive a catch-up rebuild"); } @@ -398,11 +425,16 @@ async fn catch_up_sweep_removes_legacy_keys_but_keeps_derived_keys() { kv.put(legacy_key.clone(), value.clone()).await.unwrap(); kv.put(peer_derived_key.clone(), value.clone()).await.unwrap(); // Force a rebuild (and therefore the sweep) on the next start. - let _ = kv.purge(trogon_scheduler::kv::SCHEDULES_CHECKPOINT_KEY.to_string()).await; + let _ = kv + .purge(trogon_scheduler::kv::SCHEDULES_CHECKPOINT_KEY.to_string()) + .await; let _fresh = connect_store(nats).await.unwrap(); - assert!(kv.get(&legacy_key).await.unwrap().is_none(), "legacy raw-id key must be swept"); + assert!( + kv.get(&legacy_key).await.unwrap().is_none(), + "legacy raw-id key must be swept" + ); assert!( kv.get(&peer_derived_key).await.unwrap().is_some(), "a derived-format key must never be swept (could be a peer's concurrent create)" @@ -433,10 +465,13 @@ async fn catch_up_self_heals_from_a_corrupt_checkpoint() { // A fresh client treats the corrupt checkpoint as 0 and rebuilds. let fresh = connect_store(nats).await.unwrap(); assert!( - get_schedule(&fresh.schedules_bucket, GetScheduleCommand::new(ScheduleId::parse("durable").unwrap())) - .await - .unwrap() - .is_some() + get_schedule( + &fresh.schedules_bucket, + GetScheduleCommand::new(ScheduleId::parse("durable").unwrap()) + ) + .await + .unwrap() + .is_some() ); } diff --git a/rsworkspace/crates/trogonai-proto/src/gen/mod.rs b/rsworkspace/crates/trogonai-proto/src/gen/mod.rs index 7bab0cb8d..8b7215616 100644 --- a/rsworkspace/crates/trogonai-proto/src/gen/mod.rs +++ b/rsworkspace/crates/trogonai-proto/src/gen/mod.rs @@ -177,6 +177,35 @@ pub mod trogonai { clippy::doc_lazy_continuation, clippy::module_inception )] + pub mod projections { + use super::*; + #[allow( + non_camel_case_types, + dead_code, + unused_imports, + unused_qualifications, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::uninlined_format_args, + clippy::doc_lazy_continuation, + clippy::module_inception + )] + pub mod v1 { + use super::*; + include!("trogonai.scheduler.schedules.projections.v1.mod.rs"); + } + } + #[allow( + non_camel_case_types, + dead_code, + unused_imports, + unused_qualifications, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::uninlined_format_args, + clippy::doc_lazy_continuation, + clippy::module_inception + )] pub mod state { use super::*; #[allow( diff --git a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs new file mode 100644 index 000000000..46aeedd5a --- /dev/null +++ b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs @@ -0,0 +1,33 @@ +// @generated by buffa-codegen. DO NOT EDIT. + +include!("trogonai.scheduler.schedules.projections.v1.schedule_view.rs"); +#[allow( + non_camel_case_types, + dead_code, + unused_imports, + unused_qualifications, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::uninlined_format_args, + clippy::doc_lazy_continuation, + clippy::module_inception +)] +pub mod __buffa { + #[allow(unused_imports)] + use super::*; + pub mod view { + #[allow(unused_imports)] + use super::*; + include!("trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs"); + } + /// Register this package's `Any` type entries and extension entries. + pub fn register_types(reg: &mut ::buffa::type_registry::TypeRegistry) { + reg.register_json_any(super::__SCHEDULE_VIEW_JSON_ANY); + } +} +#[doc(inline)] +pub use self::__buffa::view::ScheduleViewView; +#[doc(inline)] +pub use self::__buffa::view::ScheduleViewOwnedView; +#[doc(inline)] +pub use self::__buffa::register_types; diff --git a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs new file mode 100644 index 000000000..4232139c5 --- /dev/null +++ b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs @@ -0,0 +1,747 @@ +// @generated by buffa-codegen. DO NOT EDIT. +// source: trogonai/scheduler/schedules/projections/v1/schedule_view.proto + +/// ScheduleView is the read-model projection of a schedule's current state, +/// folded from the schedule event stream and stored as the value of each entry +/// in the schedules KV bucket. +/// +/// It is a derived, rebuildable view (not an event), serialized as protobuf so +/// its shape can evolve under protobuf's field rules rather than an ad-hoc JSON +/// contract. +#[derive(Clone, Debug, Default)] +pub struct ScheduleViewView<'a> { + /// Stable schedule id; also the schedule stream id. + /// + /// Field 1: `schedule_id` + pub schedule_id: &'a str, + /// Current lifecycle status (scheduled or paused). + /// + /// Field 2: `status` + pub status: ::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::ScheduleStatusView<'a>, + >, + /// True once a recurring schedule has run to exhaustion: it stays visible but + /// will never fire again. + /// + /// Field 3: `completed` + pub completed: ::core::option::Option, + /// The next planned occurrence, if one is armed and pending. + /// + /// Field 4: `next_occurrence_at` + pub next_occurrence_at: ::buffa::MessageFieldView< + ::buffa_types::google::protobuf::__buffa::view::TimestampView<'a>, + >, + /// The most recently recorded occurrence, if any has fired. + /// + /// Field 5: `last_occurrence_at` + pub last_occurrence_at: ::buffa::MessageFieldView< + ::buffa_types::google::protobuf::__buffa::view::TimestampView<'a>, + >, + /// The schedule definition recorded at creation. + /// + /// Field 6: `schedule` + pub schedule: ::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::ScheduleView<'a>, + >, + /// The delivery definition recorded at creation. + /// + /// Field 7: `delivery` + pub delivery: ::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::DeliveryView<'a>, + >, + /// The static message payload and headers recorded at creation. + /// + /// Field 8: `message` + pub message: ::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::MessageView<'a>, + >, +} +impl<'a> ScheduleViewView<'a> { + /// Decode from `buf`, enforcing a recursion depth limit for nested messages. + /// + /// Called by [`::buffa::MessageView::decode_view`] with [`::buffa::RECURSION_LIMIT`] + /// and by generated sub-message decode arms with `depth - 1`. + /// + /// **Not part of the public API.** Named with a leading underscore to + /// signal that it is for generated-code use only. + #[doc(hidden)] + pub fn _decode_depth( + buf: &'a [u8], + depth: u32, + ) -> ::core::result::Result { + let mut view = Self::default(); + view._merge_into_view(buf, depth)?; + ::core::result::Result::Ok(view) + } + /// Merge fields from `buf` into this view (proto merge semantics). + /// + /// Repeated fields append; singular fields last-wins; singular + /// MESSAGE fields merge recursively. Used by sub-message decode + /// arms when the same field appears multiple times on the wire. + /// + /// **Not part of the public API.** + #[doc(hidden)] + pub fn _merge_into_view( + &mut self, + buf: &'a [u8], + depth: u32, + ) -> ::core::result::Result<(), ::buffa::DecodeError> { + let _ = depth; + #[allow(unused_variables)] + let view = self; + let mut cur: &'a [u8] = buf; + while !cur.is_empty() { + let tag = ::buffa::encoding::Tag::decode(&mut cur)?; + match tag.field_number() { + 1u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 1u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + view.schedule_id = ::buffa::types::borrow_str(&mut cur)?; + } + 2u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 2u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + if depth == 0 { + return Err(::buffa::DecodeError::RecursionLimitExceeded); + } + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.status.as_mut() { + Some(existing) => existing._merge_into_view(sub, depth - 1)?, + None => { + view.status = ::buffa::MessageFieldView::set( + super::super::super::super::v1::__buffa::view::ScheduleStatusView::_decode_depth( + sub, + depth - 1, + )?, + ); + } + } + } + 3u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::Varint { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 3u32, + expected: 0u8, + actual: tag.wire_type() as u8, + }); + } + view.completed = Some(::buffa::types::decode_bool(&mut cur)?); + } + 4u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 4u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + if depth == 0 { + return Err(::buffa::DecodeError::RecursionLimitExceeded); + } + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.next_occurrence_at.as_mut() { + Some(existing) => existing._merge_into_view(sub, depth - 1)?, + None => { + view.next_occurrence_at = ::buffa::MessageFieldView::set( + ::buffa_types::google::protobuf::__buffa::view::TimestampView::_decode_depth( + sub, + depth - 1, + )?, + ); + } + } + } + 5u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 5u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + if depth == 0 { + return Err(::buffa::DecodeError::RecursionLimitExceeded); + } + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.last_occurrence_at.as_mut() { + Some(existing) => existing._merge_into_view(sub, depth - 1)?, + None => { + view.last_occurrence_at = ::buffa::MessageFieldView::set( + ::buffa_types::google::protobuf::__buffa::view::TimestampView::_decode_depth( + sub, + depth - 1, + )?, + ); + } + } + } + 6u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 6u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + if depth == 0 { + return Err(::buffa::DecodeError::RecursionLimitExceeded); + } + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.schedule.as_mut() { + Some(existing) => existing._merge_into_view(sub, depth - 1)?, + None => { + view.schedule = ::buffa::MessageFieldView::set( + super::super::super::super::v1::__buffa::view::ScheduleView::_decode_depth( + sub, + depth - 1, + )?, + ); + } + } + } + 7u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 7u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + if depth == 0 { + return Err(::buffa::DecodeError::RecursionLimitExceeded); + } + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.delivery.as_mut() { + Some(existing) => existing._merge_into_view(sub, depth - 1)?, + None => { + view.delivery = ::buffa::MessageFieldView::set( + super::super::super::super::v1::__buffa::view::DeliveryView::_decode_depth( + sub, + depth - 1, + )?, + ); + } + } + } + 8u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 8u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + if depth == 0 { + return Err(::buffa::DecodeError::RecursionLimitExceeded); + } + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.message.as_mut() { + Some(existing) => existing._merge_into_view(sub, depth - 1)?, + None => { + view.message = ::buffa::MessageFieldView::set( + super::super::super::super::v1::__buffa::view::MessageView::_decode_depth( + sub, + depth - 1, + )?, + ); + } + } + } + _ => { + ::buffa::encoding::skip_field_depth(tag, &mut cur, depth)?; + } + } + } + ::core::result::Result::Ok(()) + } +} +impl<'a> ::buffa::MessageView<'a> for ScheduleViewView<'a> { + type Owned = super::super::ScheduleView; + fn decode_view(buf: &'a [u8]) -> ::core::result::Result { + Self::_decode_depth(buf, ::buffa::RECURSION_LIMIT) + } + fn decode_view_with_limit( + buf: &'a [u8], + depth: u32, + ) -> ::core::result::Result { + Self::_decode_depth(buf, depth) + } + fn to_owned_message(&self) -> super::super::ScheduleView { + self.to_owned_from_source(None) + } + #[allow(clippy::useless_conversion, clippy::needless_update)] + fn to_owned_from_source( + &self, + __buffa_src: ::core::option::Option<&::buffa::bytes::Bytes>, + ) -> super::super::ScheduleView { + #[allow(unused_imports)] + use ::buffa::alloc::string::ToString as _; + let _ = __buffa_src; + super::super::ScheduleView { + schedule_id: self.schedule_id.to_string(), + status: match self.status.as_option() { + Some(v) => { + ::buffa::MessageField::< + super::super::super::super::v1::ScheduleStatus, + >::some(v.to_owned_from_source(__buffa_src)) + } + None => ::buffa::MessageField::none(), + }, + completed: self.completed, + next_occurrence_at: match self.next_occurrence_at.as_option() { + Some(v) => { + ::buffa::MessageField::< + ::buffa_types::google::protobuf::Timestamp, + >::some(v.to_owned_from_source(__buffa_src)) + } + None => ::buffa::MessageField::none(), + }, + last_occurrence_at: match self.last_occurrence_at.as_option() { + Some(v) => { + ::buffa::MessageField::< + ::buffa_types::google::protobuf::Timestamp, + >::some(v.to_owned_from_source(__buffa_src)) + } + None => ::buffa::MessageField::none(), + }, + schedule: match self.schedule.as_option() { + Some(v) => { + ::buffa::MessageField::< + super::super::super::super::v1::Schedule, + >::some(v.to_owned_from_source(__buffa_src)) + } + None => ::buffa::MessageField::none(), + }, + delivery: match self.delivery.as_option() { + Some(v) => { + ::buffa::MessageField::< + super::super::super::super::v1::Delivery, + >::some(v.to_owned_from_source(__buffa_src)) + } + None => ::buffa::MessageField::none(), + }, + message: match self.message.as_option() { + Some(v) => { + ::buffa::MessageField::< + super::super::super::super::v1::Message, + >::some(v.to_owned_from_source(__buffa_src)) + } + None => ::buffa::MessageField::none(), + }, + ..::core::default::Default::default() + } + } +} +impl<'a> ::buffa::ViewEncode<'a> for ScheduleViewView<'a> { + #[allow(clippy::needless_borrow, clippy::let_and_return)] + fn compute_size(&self, __cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u32; + size += 1u32 + ::buffa::types::string_encoded_len(&self.schedule_id) as u32; + if self.status.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.status.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.completed.is_some() { + size += 1u32 + ::buffa::types::BOOL_ENCODED_LEN as u32; + } + if self.next_occurrence_at.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.next_occurrence_at.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.last_occurrence_at.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.last_occurrence_at.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.schedule.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.schedule.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.delivery.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.delivery.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.message.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.message.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + size + } + #[allow(clippy::needless_borrow)] + fn write_to( + &self, + __cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::bytes::BufMut, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + ::buffa::encoding::Tag::new(1u32, ::buffa::encoding::WireType::LengthDelimited) + .encode(buf); + ::buffa::types::encode_string(&self.schedule_id, buf); + if self.status.is_set() { + ::buffa::encoding::Tag::new( + 2u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.status.write_to(__cache, buf); + } + if let Some(v) = self.completed { + ::buffa::encoding::Tag::new(3u32, ::buffa::encoding::WireType::Varint) + .encode(buf); + ::buffa::types::encode_bool(v, buf); + } + if self.next_occurrence_at.is_set() { + ::buffa::encoding::Tag::new( + 4u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.next_occurrence_at.write_to(__cache, buf); + } + if self.last_occurrence_at.is_set() { + ::buffa::encoding::Tag::new( + 5u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.last_occurrence_at.write_to(__cache, buf); + } + if self.schedule.is_set() { + ::buffa::encoding::Tag::new( + 6u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.schedule.write_to(__cache, buf); + } + if self.delivery.is_set() { + ::buffa::encoding::Tag::new( + 7u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.delivery.write_to(__cache, buf); + } + if self.message.is_set() { + ::buffa::encoding::Tag::new( + 8u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.message.write_to(__cache, buf); + } + } +} +/// Serializes this view as protobuf JSON. +/// +/// Implicit-presence fields with default values are omitted, `required` +/// fields are always emitted, explicit-presence (`optional`) fields are +/// emitted only when set, bytes fields are base64-encoded, and enum +/// values are their proto name strings. +/// +/// This impl uses `serialize_map(None)` because the number of emitted +/// fields depends on default-omission rules; serializers that require +/// known map lengths (e.g. `bincode`) will return a runtime error. +/// Use the owned message type for those formats. +impl<'__a> ::serde::Serialize for ScheduleViewView<'__a> { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + use ::serde::ser::SerializeMap as _; + let mut __map = __s.serialize_map(::core::option::Option::None)?; + { + __map.serialize_entry("scheduleId", self.schedule_id)?; + } + { + if let ::core::option::Option::Some(__v) = self.status.as_option() { + __map.serialize_entry("status", __v)?; + } + } + if let ::core::option::Option::Some(__v) = self.completed { + __map.serialize_entry("completed", &__v)?; + } + { + if let ::core::option::Option::Some(__v) = self + .next_occurrence_at + .as_option() + { + __map.serialize_entry("nextOccurrenceAt", __v)?; + } + } + { + if let ::core::option::Option::Some(__v) = self + .last_occurrence_at + .as_option() + { + __map.serialize_entry("lastOccurrenceAt", __v)?; + } + } + { + if let ::core::option::Option::Some(__v) = self.schedule.as_option() { + __map.serialize_entry("schedule", __v)?; + } + } + { + if let ::core::option::Option::Some(__v) = self.delivery.as_option() { + __map.serialize_entry("delivery", __v)?; + } + } + { + if let ::core::option::Option::Some(__v) = self.message.as_option() { + __map.serialize_entry("message", __v)?; + } + } + __map.end() + } +} +impl<'a> ::buffa::MessageName for ScheduleViewView<'a> { + const PACKAGE: &'static str = "trogonai.scheduler.schedules.projections.v1"; + const NAME: &'static str = "ScheduleView"; + const FULL_NAME: &'static str = "trogonai.scheduler.schedules.projections.v1.ScheduleView"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView"; +} +impl<'v> ::buffa::DefaultViewInstance for ScheduleViewView<'v> { + fn default_view_instance<'a>() -> &'a Self + where + Self: 'a, + { + static VALUE: ::buffa::__private::OnceBox> = ::buffa::__private::OnceBox::new(); + VALUE + .get_or_init(|| ::buffa::alloc::boxed::Box::new( + >::default(), + )) + } +} +impl ::buffa::ViewReborrow for ScheduleViewView<'static> { + type Reborrowed<'b> = ScheduleViewView<'b>; + fn reborrow<'b>(this: &'b Self) -> &'b Self::Reborrowed<'b> { + this + } +} +/** Self-contained, `'static` owned view of a `ScheduleView` message. + + Wraps [`::buffa::OwnedView`]`<`[`ScheduleViewView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. + + Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`ScheduleViewView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ +#[derive(Clone, Debug)] +pub struct ScheduleViewOwnedView(::buffa::OwnedView>); +impl ScheduleViewOwnedView { + /// Decode an owned view from a [`::buffa::bytes::Bytes`] buffer. + /// + /// The view borrows directly from the buffer's data; the buffer is + /// retained inside the returned handle. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer contains invalid + /// protobuf data. + pub fn decode( + bytes: ::buffa::bytes::Bytes, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + ScheduleViewOwnedView(::buffa::OwnedView::decode(bytes)?), + ) + } + /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, + /// max message size). + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the buffer is invalid or + /// exceeds the configured limits. + pub fn decode_with_options( + bytes: ::buffa::bytes::Bytes, + opts: &::buffa::DecodeOptions, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + ScheduleViewOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + ) + } + /// Build from an owned message via an encode → decode round-trip. + /// + /// # Errors + /// + /// Returns [`::buffa::DecodeError`] if the re-encoded bytes are + /// somehow invalid (should not happen for well-formed messages). + pub fn from_owned( + msg: &super::super::ScheduleView, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + ScheduleViewOwnedView(::buffa::OwnedView::from_owned(msg)?), + ) + } + /// Borrow the full [`ScheduleViewView`] with its lifetime tied to `&self`. + #[must_use] + pub fn view(&self) -> &ScheduleViewView<'_> { + self.0.reborrow() + } + /// Convert to the owned message type. + #[must_use] + pub fn to_owned_message(&self) -> super::super::ScheduleView { + self.0.to_owned_message() + } + /// The underlying bytes buffer. + #[must_use] + pub fn bytes(&self) -> &::buffa::bytes::Bytes { + self.0.bytes() + } + /// Consume the handle, returning the underlying bytes buffer. + #[must_use] + pub fn into_bytes(self) -> ::buffa::bytes::Bytes { + self.0.into_bytes() + } + /// Stable schedule id; also the schedule stream id. + /// + /// Field 1: `schedule_id` + #[must_use] + pub fn schedule_id(&self) -> &'_ str { + self.0.reborrow().schedule_id + } + /// Current lifecycle status (scheduled or paused). + /// + /// Field 2: `status` + #[must_use] + pub fn status( + &self, + ) -> &::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::ScheduleStatusView<'_>, + > { + &self.0.reborrow().status + } + /// True once a recurring schedule has run to exhaustion: it stays visible but + /// will never fire again. + /// + /// Field 3: `completed` + #[must_use] + pub fn completed(&self) -> ::core::option::Option { + self.0.reborrow().completed + } + /// The next planned occurrence, if one is armed and pending. + /// + /// Field 4: `next_occurrence_at` + #[must_use] + pub fn next_occurrence_at( + &self, + ) -> &::buffa::MessageFieldView< + ::buffa_types::google::protobuf::__buffa::view::TimestampView<'_>, + > { + &self.0.reborrow().next_occurrence_at + } + /// The most recently recorded occurrence, if any has fired. + /// + /// Field 5: `last_occurrence_at` + #[must_use] + pub fn last_occurrence_at( + &self, + ) -> &::buffa::MessageFieldView< + ::buffa_types::google::protobuf::__buffa::view::TimestampView<'_>, + > { + &self.0.reborrow().last_occurrence_at + } + /// The schedule definition recorded at creation. + /// + /// Field 6: `schedule` + #[must_use] + pub fn schedule( + &self, + ) -> &::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::ScheduleView<'_>, + > { + &self.0.reborrow().schedule + } + /// The delivery definition recorded at creation. + /// + /// Field 7: `delivery` + #[must_use] + pub fn delivery( + &self, + ) -> &::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::DeliveryView<'_>, + > { + &self.0.reborrow().delivery + } + /// The static message payload and headers recorded at creation. + /// + /// Field 8: `message` + #[must_use] + pub fn message( + &self, + ) -> &::buffa::MessageFieldView< + super::super::super::super::v1::__buffa::view::MessageView<'_>, + > { + &self.0.reborrow().message + } +} +impl ::core::convert::From<::buffa::OwnedView>> +for ScheduleViewOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + ScheduleViewOwnedView(inner) + } +} +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: ScheduleViewOwnedView) -> Self { + wrapper.0 + } +} +impl ::core::convert::AsRef<::buffa::OwnedView>> +for ScheduleViewOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { + &self.0 + } +} +impl ::buffa::HasMessageView for super::super::ScheduleView { + type View<'a> = ScheduleViewView<'a>; + type ViewHandle = ScheduleViewOwnedView; +} +impl ::serde::Serialize for ScheduleViewOwnedView { + fn serialize<__S: ::serde::Serializer>( + &self, + __s: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + ::serde::Serialize::serialize(&self.0, __s) + } +} diff --git a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs new file mode 100644 index 000000000..b60e1d595 --- /dev/null +++ b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs @@ -0,0 +1,406 @@ +// @generated by buffa-codegen. DO NOT EDIT. +// source: trogonai/scheduler/schedules/projections/v1/schedule_view.proto + +/// ScheduleView is the read-model projection of a schedule's current state, +/// folded from the schedule event stream and stored as the value of each entry +/// in the schedules KV bucket. +/// +/// It is a derived, rebuildable view (not an event), serialized as protobuf so +/// its shape can evolve under protobuf's field rules rather than an ad-hoc JSON +/// contract. +#[derive(Clone, PartialEq, Default)] +#[derive(::serde::Serialize, ::serde::Deserialize)] +#[serde(default)] +pub struct ScheduleView { + /// Stable schedule id; also the schedule stream id. + /// + /// Field 1: `schedule_id` + #[serde( + rename = "scheduleId", + alias = "schedule_id", + with = "::buffa::json_helpers::proto_string" + )] + pub schedule_id: ::buffa::alloc::string::String, + /// Current lifecycle status (scheduled or paused). + /// + /// Field 2: `status` + #[serde(rename = "status")] + pub status: ::buffa::MessageField, + /// True once a recurring schedule has run to exhaustion: it stays visible but + /// will never fire again. + /// + /// Field 3: `completed` + #[serde( + rename = "completed", + skip_serializing_if = "::core::option::Option::is_none" + )] + pub completed: ::core::option::Option, + /// The next planned occurrence, if one is armed and pending. + /// + /// Field 4: `next_occurrence_at` + #[serde( + rename = "nextOccurrenceAt", + alias = "next_occurrence_at", + skip_serializing_if = "::buffa::json_helpers::skip_if::is_unset_message_field" + )] + pub next_occurrence_at: ::buffa::MessageField< + ::buffa_types::google::protobuf::Timestamp, + >, + /// The most recently recorded occurrence, if any has fired. + /// + /// Field 5: `last_occurrence_at` + #[serde( + rename = "lastOccurrenceAt", + alias = "last_occurrence_at", + skip_serializing_if = "::buffa::json_helpers::skip_if::is_unset_message_field" + )] + pub last_occurrence_at: ::buffa::MessageField< + ::buffa_types::google::protobuf::Timestamp, + >, + /// The schedule definition recorded at creation. + /// + /// Field 6: `schedule` + #[serde(rename = "schedule")] + pub schedule: ::buffa::MessageField, + /// The delivery definition recorded at creation. + /// + /// Field 7: `delivery` + #[serde(rename = "delivery")] + pub delivery: ::buffa::MessageField, + /// The static message payload and headers recorded at creation. + /// + /// Field 8: `message` + #[serde(rename = "message")] + pub message: ::buffa::MessageField, +} +impl ::core::fmt::Debug for ScheduleView { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.debug_struct("ScheduleView") + .field("schedule_id", &self.schedule_id) + .field("status", &self.status) + .field("completed", &self.completed) + .field("next_occurrence_at", &self.next_occurrence_at) + .field("last_occurrence_at", &self.last_occurrence_at) + .field("schedule", &self.schedule) + .field("delivery", &self.delivery) + .field("message", &self.message) + .finish() + } +} +impl ScheduleView { + /// Protobuf type URL for this message, for use with `Any::pack` and + /// `Any::unpack_if`. + /// + /// Format: `type.googleapis.com/` + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView"; +} +impl ScheduleView { + #[must_use = "with_* setters return `self` by value; assign or chain the result"] + #[inline] + ///Sets [`Self::completed`] to `Some(value)`, consuming and returning `self`. + pub fn with_completed(mut self, value: bool) -> Self { + self.completed = Some(value); + self + } +} +impl ::buffa::DefaultInstance for ScheduleView { + fn default_instance() -> &'static Self { + static VALUE: ::buffa::__private::OnceBox = ::buffa::__private::OnceBox::new(); + VALUE.get_or_init(|| ::buffa::alloc::boxed::Box::new(Self::default())) + } +} +impl ::buffa::MessageName for ScheduleView { + const PACKAGE: &'static str = "trogonai.scheduler.schedules.projections.v1"; + const NAME: &'static str = "ScheduleView"; + const FULL_NAME: &'static str = "trogonai.scheduler.schedules.projections.v1.ScheduleView"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView"; +} +impl ::buffa::Message for ScheduleView { + /// Returns the total encoded size in bytes. + /// + /// The result is a `u32`; the protobuf specification requires all + /// messages to fit within 2 GiB (2,147,483,647 bytes), so a + /// compliant message will never overflow this type. + #[allow(clippy::let_and_return)] + fn compute_size(&self, __cache: &mut ::buffa::SizeCache) -> u32 { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + let mut size = 0u32; + size += 1u32 + ::buffa::types::string_encoded_len(&self.schedule_id) as u32; + if self.status.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.status.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.completed.is_some() { + size += 1u32 + ::buffa::types::BOOL_ENCODED_LEN as u32; + } + if self.next_occurrence_at.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.next_occurrence_at.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.last_occurrence_at.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.last_occurrence_at.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.schedule.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.schedule.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.delivery.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.delivery.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + if self.message.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.message.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u32 + ::buffa::encoding::varint_len(inner_size as u64) as u32 + + inner_size; + } + size + } + fn write_to( + &self, + __cache: &mut ::buffa::SizeCache, + buf: &mut impl ::buffa::bytes::BufMut, + ) { + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + ::buffa::encoding::Tag::new(1u32, ::buffa::encoding::WireType::LengthDelimited) + .encode(buf); + ::buffa::types::encode_string(&self.schedule_id, buf); + if self.status.is_set() { + ::buffa::encoding::Tag::new( + 2u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.status.write_to(__cache, buf); + } + if let Some(v) = self.completed { + ::buffa::encoding::Tag::new(3u32, ::buffa::encoding::WireType::Varint) + .encode(buf); + ::buffa::types::encode_bool(v, buf); + } + if self.next_occurrence_at.is_set() { + ::buffa::encoding::Tag::new( + 4u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.next_occurrence_at.write_to(__cache, buf); + } + if self.last_occurrence_at.is_set() { + ::buffa::encoding::Tag::new( + 5u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.last_occurrence_at.write_to(__cache, buf); + } + if self.schedule.is_set() { + ::buffa::encoding::Tag::new( + 6u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.schedule.write_to(__cache, buf); + } + if self.delivery.is_set() { + ::buffa::encoding::Tag::new( + 7u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.delivery.write_to(__cache, buf); + } + if self.message.is_set() { + ::buffa::encoding::Tag::new( + 8u32, + ::buffa::encoding::WireType::LengthDelimited, + ) + .encode(buf); + ::buffa::encoding::encode_varint(__cache.consume_next() as u64, buf); + self.message.write_to(__cache, buf); + } + } + fn merge_field( + &mut self, + tag: ::buffa::encoding::Tag, + buf: &mut impl ::buffa::bytes::Buf, + depth: u32, + ) -> ::core::result::Result<(), ::buffa::DecodeError> { + #[allow(unused_imports)] + use ::buffa::bytes::Buf as _; + #[allow(unused_imports)] + use ::buffa::Enumeration as _; + match tag.field_number() { + 1u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 1u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::types::merge_string(&mut self.schedule_id, buf)?; + } + 2u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 2u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::Message::merge_length_delimited( + self.status.get_or_insert_default(), + buf, + depth, + )?; + } + 3u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::Varint { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 3u32, + expected: 0u8, + actual: tag.wire_type() as u8, + }); + } + self.completed = ::core::option::Option::Some( + ::buffa::types::decode_bool(buf)?, + ); + } + 4u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 4u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::Message::merge_length_delimited( + self.next_occurrence_at.get_or_insert_default(), + buf, + depth, + )?; + } + 5u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 5u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::Message::merge_length_delimited( + self.last_occurrence_at.get_or_insert_default(), + buf, + depth, + )?; + } + 6u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 6u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::Message::merge_length_delimited( + self.schedule.get_or_insert_default(), + buf, + depth, + )?; + } + 7u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 7u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::Message::merge_length_delimited( + self.delivery.get_or_insert_default(), + buf, + depth, + )?; + } + 8u32 => { + if tag.wire_type() != ::buffa::encoding::WireType::LengthDelimited { + return ::core::result::Result::Err(::buffa::DecodeError::WireTypeMismatch { + field_number: 8u32, + expected: 2u8, + actual: tag.wire_type() as u8, + }); + } + ::buffa::Message::merge_length_delimited( + self.message.get_or_insert_default(), + buf, + depth, + )?; + } + _ => { + ::buffa::encoding::skip_field_depth(tag, buf, depth)?; + } + } + ::core::result::Result::Ok(()) + } + fn clear(&mut self) { + self.schedule_id.clear(); + self.status = ::buffa::MessageField::none(); + self.completed = ::core::option::Option::None; + self.next_occurrence_at = ::buffa::MessageField::none(); + self.last_occurrence_at = ::buffa::MessageField::none(); + self.schedule = ::buffa::MessageField::none(); + self.delivery = ::buffa::MessageField::none(); + self.message = ::buffa::MessageField::none(); + } +} +impl ::buffa::json_helpers::ProtoElemJson for ScheduleView { + fn serialize_proto_json( + v: &Self, + s: S, + ) -> ::core::result::Result { + ::serde::Serialize::serialize(v, s) + } + fn deserialize_proto_json<'de, D: ::serde::Deserializer<'de>>( + d: D, + ) -> ::core::result::Result { + ::deserialize(d) + } +} +#[doc(hidden)] +pub const __SCHEDULE_VIEW_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView", + to_json: ::buffa::type_registry::any_to_json::, + from_json: ::buffa::type_registry::any_from_json::, + is_wkt: false, +}; diff --git a/rsworkspace/crates/trogonai-proto/src/scheduler/schedules/mod.rs b/rsworkspace/crates/trogonai-proto/src/scheduler/schedules/mod.rs index 7e40c93a9..810dbe6ba 100644 --- a/rsworkspace/crates/trogonai-proto/src/scheduler/schedules/mod.rs +++ b/rsworkspace/crates/trogonai-proto/src/scheduler/schedules/mod.rs @@ -4,6 +4,10 @@ pub mod checkpoints_v1 { pub use crate::r#gen::trogonai::scheduler::schedules::checkpoints::v1::*; } +pub mod projections_v1 { + pub use crate::r#gen::trogonai::scheduler::schedules::projections::v1::*; +} + pub mod state_v1 { pub use crate::r#gen::trogonai::scheduler::schedules::state::v1::*; } From e29861e2c62f8263f1f1fdb1bd40c6183744a29b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 19 Jun 2026 04:41:11 -0400 Subject: [PATCH 11/11] refactor(scheduler): name the read-model projection message projections.v1.Schedule Its own package already marks it as the read-model view, so the View suffix is redundant; the separate package keeps its schema evolution isolated from the event and command schemas even though the bare name collides with schedules.v1.Schedule. Signed-off-by: Yordis Prieto --- .../{schedule_view.proto => schedule.proto} | 16 +-- .../trogon-scheduler/src/projections/mod.rs | 2 +- .../src/projections/schedules.rs | 33 +++--- .../trogon-scheduler/src/queries/get.rs | 4 +- .../trogon-scheduler/src/queries/list.rs | 4 +- ....scheduler.schedules.projections.v1.mod.rs | 10 +- ...hedules.projections.v1.schedule.__view.rs} | 106 +++++++++--------- ...uler.schedules.projections.v1.schedule.rs} | 52 ++++----- 8 files changed, 116 insertions(+), 111 deletions(-) rename proto/trogonai/scheduler/schedules/projections/v1/{schedule_view.proto => schedule.proto} (73%) rename rsworkspace/crates/trogonai-proto/src/gen/{trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs => trogonai.scheduler.schedules.projections.v1.schedule.__view.rs} (89%) rename rsworkspace/crates/trogonai-proto/src/gen/{trogonai.scheduler.schedules.projections.v1.schedule_view.rs => trogonai.scheduler.schedules.projections.v1.schedule.rs} (91%) diff --git a/proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto b/proto/trogonai/scheduler/schedules/projections/v1/schedule.proto similarity index 73% rename from proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto rename to proto/trogonai/scheduler/schedules/projections/v1/schedule.proto index 9290b99ab..3508660e3 100644 --- a/proto/trogonai/scheduler/schedules/projections/v1/schedule_view.proto +++ b/proto/trogonai/scheduler/schedules/projections/v1/schedule.proto @@ -8,14 +8,16 @@ import "trogonai/scheduler/schedules/v1/message.proto"; import "trogonai/scheduler/schedules/v1/schedule.proto"; import "trogonai/scheduler/schedules/v1/schedule_status.proto"; -// ScheduleView is the read-model projection of a schedule's current state, -// folded from the schedule event stream and stored as the value of each entry -// in the schedules KV bucket. +// Schedule is the read-model projection of a schedule's current state, folded +// from the schedule event stream and stored as the value of each entry in the +// schedules KV bucket. // -// It is a derived, rebuildable view (not an event), serialized as protobuf so -// its shape can evolve under protobuf's field rules rather than an ad-hoc JSON -// contract. -message ScheduleView { +// It is intentionally independent of schedules.v1.Schedule (the scheduling +// strategy, which appears here only as a nested field): this is a derived, +// rebuildable read view, so its shape evolves on its own under protobuf's field +// rules. Keeping it in its own package isolates those changes from the event and +// command schemas. +message Schedule { // Stable schedule id; also the schedule stream id. string schedule_id = 1 [features.field_presence = LEGACY_REQUIRED]; // Current lifecycle status (scheduled or paused). diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs index ffdcedc82..966c8ebae 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/mod.rs @@ -34,4 +34,4 @@ mod schedules; -pub(crate) use schedules::{catch_up_schedules_read_model, decode_schedule_view, project_appended_events}; +pub(crate) use schedules::{catch_up_schedules_read_model, decode_projected_schedule, project_appended_events}; diff --git a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs index bdfee8987..e5da3ac10 100644 --- a/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs +++ b/rsworkspace/crates/trogon-scheduler/src/projections/schedules.rs @@ -318,27 +318,28 @@ fn project_message(message: &v1::Message) -> Result Vec { - buffa::Message::encode_to_vec(&schedule_view_from_read_model(job)) +/// Encodes a read-model schedule to its `projections.v1.Schedule` protobuf KV value. +pub(crate) fn encode_projected_schedule(job: &Schedule) -> Vec { + buffa::Message::encode_to_vec(&projected_schedule_to_proto(job)) } -/// Decodes a `ScheduleView` protobuf KV value back into a read-model schedule. -pub(crate) fn decode_schedule_view(value: &[u8]) -> Result { - let view = ::decode_from_slice(value).map_err(|source| { +/// Decodes a `projections.v1.Schedule` protobuf KV value back into a read-model schedule. +pub(crate) fn decode_projected_schedule(value: &[u8]) -> Result { + let view = ::decode_from_slice(value).map_err(|source| { SchedulerError::kv_source( "failed to decode projected schedule view", std::io::Error::other(source.to_string()), ) })?; - read_model_from_schedule_view(&view) + projected_schedule_from_proto(&view) } -fn read_model_from_schedule_view(view: &projections_v1::ScheduleView) -> Result { +fn projected_schedule_from_proto(view: &projections_v1::Schedule) -> Result { let decode_error = |context: &'static str| { SchedulerError::kv_source("projected schedule view is malformed", std::io::Error::other(context)) }; @@ -370,9 +371,9 @@ fn read_model_from_schedule_view(view: &projections_v1::ScheduleView) -> Result< }) } -fn schedule_view_from_read_model(job: &Schedule) -> projections_v1::ScheduleView { +fn projected_schedule_to_proto(job: &Schedule) -> projections_v1::Schedule { use buffa::MessageField; - projections_v1::ScheduleView { + projections_v1::Schedule { schedule_id: job.id.clone(), status: MessageField::some(status_to_proto(job.status)), completed: Some(job.completed), @@ -835,7 +836,7 @@ async fn read_projected_schedule(bucket: &kv::Store, id: &str) -> Result Result { @@ -895,7 +896,7 @@ async fn maybe_advance_read_model_checkpoint( async fn apply_projection_change(kv: &kv::Store, change: &ProjectionChange) -> Result<(), SchedulerError> { match change { ProjectionChange::Upsert(job) => { - let value = encode_schedule_view(job); + let value = encode_projected_schedule(job); kv.put(read_model_key(&job.id), value.into()) .await .map_err(|source| SchedulerError::kv_source("failed to store projected job state", source))?; @@ -1182,7 +1183,7 @@ mod tests { } #[test] - fn schedule_view_codec_round_trips_every_field() { + fn projected_schedule_codec_round_trips_every_field() { let dt = |s: &str| -> DateTime { s.parse().unwrap() }; let cases = [ Schedule { @@ -1232,7 +1233,7 @@ mod tests { ]; for job in cases { - let round_tripped = decode_schedule_view(&encode_schedule_view(&job)).unwrap(); + let round_tripped = decode_projected_schedule(&encode_projected_schedule(&job)).unwrap(); assert_eq!(round_tripped, job); } } diff --git a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs index d18174b8a..16d619e4f 100644 --- a/rsworkspace/crates/trogon-scheduler/src/queries/get.rs +++ b/rsworkspace/crates/trogon-scheduler/src/queries/get.rs @@ -1,6 +1,6 @@ use async_nats::jetstream::kv; -use crate::{error::SchedulerError, kv::read_model_key, projections::decode_schedule_view, read_model::Schedule}; +use crate::{error::SchedulerError, kv::read_model_key, projections::decode_projected_schedule, read_model::Schedule}; use super::ScheduleId; @@ -24,5 +24,5 @@ pub async fn run(store: &kv::Store, command: GetSchedule) -> Result Result jobs.push(job), Err(source) => { tracing::warn!(%key, %source, "skipping unreadable projected schedule entry during list"); diff --git a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs index 46aeedd5a..e8537e1fa 100644 --- a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs +++ b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.mod.rs @@ -1,6 +1,6 @@ // @generated by buffa-codegen. DO NOT EDIT. -include!("trogonai.scheduler.schedules.projections.v1.schedule_view.rs"); +include!("trogonai.scheduler.schedules.projections.v1.schedule.rs"); #[allow( non_camel_case_types, dead_code, @@ -18,16 +18,16 @@ pub mod __buffa { pub mod view { #[allow(unused_imports)] use super::*; - include!("trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs"); + include!("trogonai.scheduler.schedules.projections.v1.schedule.__view.rs"); } /// Register this package's `Any` type entries and extension entries. pub fn register_types(reg: &mut ::buffa::type_registry::TypeRegistry) { - reg.register_json_any(super::__SCHEDULE_VIEW_JSON_ANY); + reg.register_json_any(super::__SCHEDULE_JSON_ANY); } } #[doc(inline)] -pub use self::__buffa::view::ScheduleViewView; +pub use self::__buffa::view::ScheduleView; #[doc(inline)] -pub use self::__buffa::view::ScheduleViewOwnedView; +pub use self::__buffa::view::ScheduleOwnedView; #[doc(inline)] pub use self::__buffa::register_types; diff --git a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule.__view.rs similarity index 89% rename from rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs rename to rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule.__view.rs index 4232139c5..599e02678 100644 --- a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.__view.rs +++ b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule.__view.rs @@ -1,15 +1,17 @@ // @generated by buffa-codegen. DO NOT EDIT. -// source: trogonai/scheduler/schedules/projections/v1/schedule_view.proto +// source: trogonai/scheduler/schedules/projections/v1/schedule.proto -/// ScheduleView is the read-model projection of a schedule's current state, -/// folded from the schedule event stream and stored as the value of each entry -/// in the schedules KV bucket. +/// Schedule is the read-model projection of a schedule's current state, folded +/// from the schedule event stream and stored as the value of each entry in the +/// schedules KV bucket. /// -/// It is a derived, rebuildable view (not an event), serialized as protobuf so -/// its shape can evolve under protobuf's field rules rather than an ad-hoc JSON -/// contract. +/// It is intentionally independent of schedules.v1.Schedule (the scheduling +/// strategy, which appears here only as a nested field): this is a derived, +/// rebuildable read view, so its shape evolves on its own under protobuf's field +/// rules. Keeping it in its own package isolates those changes from the event and +/// command schemas. #[derive(Clone, Debug, Default)] -pub struct ScheduleViewView<'a> { +pub struct ScheduleView<'a> { /// Stable schedule id; also the schedule stream id. /// /// Field 1: `schedule_id` @@ -56,7 +58,7 @@ pub struct ScheduleViewView<'a> { super::super::super::super::v1::__buffa::view::MessageView<'a>, >, } -impl<'a> ScheduleViewView<'a> { +impl<'a> ScheduleView<'a> { /// Decode from `buf`, enforcing a recursion depth limit for nested messages. /// /// Called by [`::buffa::MessageView::decode_view`] with [`::buffa::RECURSION_LIMIT`] @@ -265,8 +267,8 @@ impl<'a> ScheduleViewView<'a> { ::core::result::Result::Ok(()) } } -impl<'a> ::buffa::MessageView<'a> for ScheduleViewView<'a> { - type Owned = super::super::ScheduleView; +impl<'a> ::buffa::MessageView<'a> for ScheduleView<'a> { + type Owned = super::super::Schedule; fn decode_view(buf: &'a [u8]) -> ::core::result::Result { Self::_decode_depth(buf, ::buffa::RECURSION_LIMIT) } @@ -276,18 +278,18 @@ impl<'a> ::buffa::MessageView<'a> for ScheduleViewView<'a> { ) -> ::core::result::Result { Self::_decode_depth(buf, depth) } - fn to_owned_message(&self) -> super::super::ScheduleView { + fn to_owned_message(&self) -> super::super::Schedule { self.to_owned_from_source(None) } #[allow(clippy::useless_conversion, clippy::needless_update)] fn to_owned_from_source( &self, __buffa_src: ::core::option::Option<&::buffa::bytes::Bytes>, - ) -> super::super::ScheduleView { + ) -> super::super::Schedule { #[allow(unused_imports)] use ::buffa::alloc::string::ToString as _; let _ = __buffa_src; - super::super::ScheduleView { + super::super::Schedule { schedule_id: self.schedule_id.to_string(), status: match self.status.as_option() { Some(v) => { @@ -342,7 +344,7 @@ impl<'a> ::buffa::MessageView<'a> for ScheduleViewView<'a> { } } } -impl<'a> ::buffa::ViewEncode<'a> for ScheduleViewView<'a> { +impl<'a> ::buffa::ViewEncode<'a> for ScheduleView<'a> { #[allow(clippy::needless_borrow, clippy::let_and_return)] fn compute_size(&self, __cache: &mut ::buffa::SizeCache) -> u32 { #[allow(unused_imports)] @@ -485,7 +487,7 @@ impl<'a> ::buffa::ViewEncode<'a> for ScheduleViewView<'a> { /// fields depends on default-omission rules; serializers that require /// known map lengths (e.g. `bincode`) will return a runtime error. /// Use the owned message type for those formats. -impl<'__a> ::serde::Serialize for ScheduleViewView<'__a> { +impl<'__a> ::serde::Serialize for ScheduleView<'__a> { fn serialize<__S: ::serde::Serializer>( &self, __s: __S, @@ -537,38 +539,38 @@ impl<'__a> ::serde::Serialize for ScheduleViewView<'__a> { __map.end() } } -impl<'a> ::buffa::MessageName for ScheduleViewView<'a> { +impl<'a> ::buffa::MessageName for ScheduleView<'a> { const PACKAGE: &'static str = "trogonai.scheduler.schedules.projections.v1"; - const NAME: &'static str = "ScheduleView"; - const FULL_NAME: &'static str = "trogonai.scheduler.schedules.projections.v1.ScheduleView"; - const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView"; + const NAME: &'static str = "Schedule"; + const FULL_NAME: &'static str = "trogonai.scheduler.schedules.projections.v1.Schedule"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.Schedule"; } -impl<'v> ::buffa::DefaultViewInstance for ScheduleViewView<'v> { +impl<'v> ::buffa::DefaultViewInstance for ScheduleView<'v> { fn default_view_instance<'a>() -> &'a Self where Self: 'a, { - static VALUE: ::buffa::__private::OnceBox> = ::buffa::__private::OnceBox::new(); + static VALUE: ::buffa::__private::OnceBox> = ::buffa::__private::OnceBox::new(); VALUE .get_or_init(|| ::buffa::alloc::boxed::Box::new( - >::default(), + >::default(), )) } } -impl ::buffa::ViewReborrow for ScheduleViewView<'static> { - type Reborrowed<'b> = ScheduleViewView<'b>; +impl ::buffa::ViewReborrow for ScheduleView<'static> { + type Reborrowed<'b> = ScheduleView<'b>; fn reborrow<'b>(this: &'b Self) -> &'b Self::Reborrowed<'b> { this } } -/** Self-contained, `'static` owned view of a `ScheduleView` message. +/** Self-contained, `'static` owned view of a `Schedule` message. - Wraps [`::buffa::OwnedView`]`<`[`ScheduleViewView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. + Wraps [`::buffa::OwnedView`]`<`[`ScheduleView`]`<'static>>`: the decoded view and the [`::buffa::bytes::Bytes`] buffer it borrows from travel together, so the handle is `'static` and `Send + Sync` — suitable for async handlers, spawned tasks, and anywhere a `'static` bound is required. - Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`ScheduleViewView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ + Field accessors return borrows tied to `&self`. Use [`Self::view`] to get the full [`ScheduleView`] when you need struct patterns, iteration helpers, or to pass the view to lifetime-parameterised code.*/ #[derive(Clone, Debug)] -pub struct ScheduleViewOwnedView(::buffa::OwnedView>); -impl ScheduleViewOwnedView { +pub struct ScheduleOwnedView(::buffa::OwnedView>); +impl ScheduleOwnedView { /// Decode an owned view from a [`::buffa::bytes::Bytes`] buffer. /// /// The view borrows directly from the buffer's data; the buffer is @@ -581,9 +583,7 @@ impl ScheduleViewOwnedView { pub fn decode( bytes: ::buffa::bytes::Bytes, ) -> ::core::result::Result { - ::core::result::Result::Ok( - ScheduleViewOwnedView(::buffa::OwnedView::decode(bytes)?), - ) + ::core::result::Result::Ok(ScheduleOwnedView(::buffa::OwnedView::decode(bytes)?)) } /// Decode with custom [`::buffa::DecodeOptions`] (recursion limit, /// max message size). @@ -597,7 +597,7 @@ impl ScheduleViewOwnedView { opts: &::buffa::DecodeOptions, ) -> ::core::result::Result { ::core::result::Result::Ok( - ScheduleViewOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), + ScheduleOwnedView(::buffa::OwnedView::decode_with_options(bytes, opts)?), ) } /// Build from an owned message via an encode → decode round-trip. @@ -607,20 +607,20 @@ impl ScheduleViewOwnedView { /// Returns [`::buffa::DecodeError`] if the re-encoded bytes are /// somehow invalid (should not happen for well-formed messages). pub fn from_owned( - msg: &super::super::ScheduleView, + msg: &super::super::Schedule, ) -> ::core::result::Result { ::core::result::Result::Ok( - ScheduleViewOwnedView(::buffa::OwnedView::from_owned(msg)?), + ScheduleOwnedView(::buffa::OwnedView::from_owned(msg)?), ) } - /// Borrow the full [`ScheduleViewView`] with its lifetime tied to `&self`. + /// Borrow the full [`ScheduleView`] with its lifetime tied to `&self`. #[must_use] - pub fn view(&self) -> &ScheduleViewView<'_> { + pub fn view(&self) -> &ScheduleView<'_> { self.0.reborrow() } /// Convert to the owned message type. #[must_use] - pub fn to_owned_message(&self) -> super::super::ScheduleView { + pub fn to_owned_message(&self) -> super::super::Schedule { self.0.to_owned_message() } /// The underlying bytes buffer. @@ -715,29 +715,29 @@ impl ScheduleViewOwnedView { &self.0.reborrow().message } } -impl ::core::convert::From<::buffa::OwnedView>> -for ScheduleViewOwnedView { - fn from(inner: ::buffa::OwnedView>) -> Self { - ScheduleViewOwnedView(inner) +impl ::core::convert::From<::buffa::OwnedView>> +for ScheduleOwnedView { + fn from(inner: ::buffa::OwnedView>) -> Self { + ScheduleOwnedView(inner) } } -impl ::core::convert::From -for ::buffa::OwnedView> { - fn from(wrapper: ScheduleViewOwnedView) -> Self { +impl ::core::convert::From +for ::buffa::OwnedView> { + fn from(wrapper: ScheduleOwnedView) -> Self { wrapper.0 } } -impl ::core::convert::AsRef<::buffa::OwnedView>> -for ScheduleViewOwnedView { - fn as_ref(&self) -> &::buffa::OwnedView> { +impl ::core::convert::AsRef<::buffa::OwnedView>> +for ScheduleOwnedView { + fn as_ref(&self) -> &::buffa::OwnedView> { &self.0 } } -impl ::buffa::HasMessageView for super::super::ScheduleView { - type View<'a> = ScheduleViewView<'a>; - type ViewHandle = ScheduleViewOwnedView; +impl ::buffa::HasMessageView for super::super::Schedule { + type View<'a> = ScheduleView<'a>; + type ViewHandle = ScheduleOwnedView; } -impl ::serde::Serialize for ScheduleViewOwnedView { +impl ::serde::Serialize for ScheduleOwnedView { fn serialize<__S: ::serde::Serializer>( &self, __s: __S, diff --git a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule.rs similarity index 91% rename from rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs rename to rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule.rs index b60e1d595..066ef0e7f 100644 --- a/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule_view.rs +++ b/rsworkspace/crates/trogonai-proto/src/gen/trogonai.scheduler.schedules.projections.v1.schedule.rs @@ -1,17 +1,19 @@ // @generated by buffa-codegen. DO NOT EDIT. -// source: trogonai/scheduler/schedules/projections/v1/schedule_view.proto +// source: trogonai/scheduler/schedules/projections/v1/schedule.proto -/// ScheduleView is the read-model projection of a schedule's current state, -/// folded from the schedule event stream and stored as the value of each entry -/// in the schedules KV bucket. +/// Schedule is the read-model projection of a schedule's current state, folded +/// from the schedule event stream and stored as the value of each entry in the +/// schedules KV bucket. /// -/// It is a derived, rebuildable view (not an event), serialized as protobuf so -/// its shape can evolve under protobuf's field rules rather than an ad-hoc JSON -/// contract. +/// It is intentionally independent of schedules.v1.Schedule (the scheduling +/// strategy, which appears here only as a nested field): this is a derived, +/// rebuildable read view, so its shape evolves on its own under protobuf's field +/// rules. Keeping it in its own package isolates those changes from the event and +/// command schemas. #[derive(Clone, PartialEq, Default)] #[derive(::serde::Serialize, ::serde::Deserialize)] #[serde(default)] -pub struct ScheduleView { +pub struct Schedule { /// Stable schedule id; also the schedule stream id. /// /// Field 1: `schedule_id` @@ -73,9 +75,9 @@ pub struct ScheduleView { #[serde(rename = "message")] pub message: ::buffa::MessageField, } -impl ::core::fmt::Debug for ScheduleView { +impl ::core::fmt::Debug for Schedule { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - f.debug_struct("ScheduleView") + f.debug_struct("Schedule") .field("schedule_id", &self.schedule_id) .field("status", &self.status) .field("completed", &self.completed) @@ -87,14 +89,14 @@ impl ::core::fmt::Debug for ScheduleView { .finish() } } -impl ScheduleView { +impl Schedule { /// Protobuf type URL for this message, for use with `Any::pack` and /// `Any::unpack_if`. /// /// Format: `type.googleapis.com/` - pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView"; + pub const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.Schedule"; } -impl ScheduleView { +impl Schedule { #[must_use = "with_* setters return `self` by value; assign or chain the result"] #[inline] ///Sets [`Self::completed`] to `Some(value)`, consuming and returning `self`. @@ -103,19 +105,19 @@ impl ScheduleView { self } } -impl ::buffa::DefaultInstance for ScheduleView { +impl ::buffa::DefaultInstance for Schedule { fn default_instance() -> &'static Self { - static VALUE: ::buffa::__private::OnceBox = ::buffa::__private::OnceBox::new(); + static VALUE: ::buffa::__private::OnceBox = ::buffa::__private::OnceBox::new(); VALUE.get_or_init(|| ::buffa::alloc::boxed::Box::new(Self::default())) } } -impl ::buffa::MessageName for ScheduleView { +impl ::buffa::MessageName for Schedule { const PACKAGE: &'static str = "trogonai.scheduler.schedules.projections.v1"; - const NAME: &'static str = "ScheduleView"; - const FULL_NAME: &'static str = "trogonai.scheduler.schedules.projections.v1.ScheduleView"; - const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView"; + const NAME: &'static str = "Schedule"; + const FULL_NAME: &'static str = "trogonai.scheduler.schedules.projections.v1.Schedule"; + const TYPE_URL: &'static str = "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.Schedule"; } -impl ::buffa::Message for ScheduleView { +impl ::buffa::Message for Schedule { /// Returns the total encoded size in bytes. /// /// The result is a `u32`; the protobuf specification requires all @@ -384,7 +386,7 @@ impl ::buffa::Message for ScheduleView { self.message = ::buffa::MessageField::none(); } } -impl ::buffa::json_helpers::ProtoElemJson for ScheduleView { +impl ::buffa::json_helpers::ProtoElemJson for Schedule { fn serialize_proto_json( v: &Self, s: S, @@ -398,9 +400,9 @@ impl ::buffa::json_helpers::ProtoElemJson for ScheduleView { } } #[doc(hidden)] -pub const __SCHEDULE_VIEW_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { - type_url: "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.ScheduleView", - to_json: ::buffa::type_registry::any_to_json::, - from_json: ::buffa::type_registry::any_from_json::, +pub const __SCHEDULE_JSON_ANY: ::buffa::type_registry::JsonAnyEntry = ::buffa::type_registry::JsonAnyEntry { + type_url: "type.googleapis.com/trogonai.scheduler.schedules.projections.v1.Schedule", + to_json: ::buffa::type_registry::any_to_json::, + from_json: ::buffa::type_registry::any_from_json::, is_wkt: false, };