1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::event_log::{
7 sanitize_topic_component, AnyEventLog, EventLog, LogError, LogEvent, Topic,
8};
9
10pub const WAITPOINT_STATE_TOPIC_PREFIX: &str = "waitpoint.state.";
11pub const WAITPOINT_WAITS_TOPIC: &str = "waitpoint.waits";
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum WaitpointStatus {
16 #[default]
17 Open,
18 Completed,
19 Cancelled,
20}
21
22impl WaitpointStatus {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::Open => "open",
26 Self::Completed => "completed",
27 Self::Cancelled => "cancelled",
28 }
29 }
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum WaitpointWaitStatus {
35 Completed,
36 Cancelled,
37 TimedOut,
38 Interrupted,
39}
40
41impl WaitpointWaitStatus {
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Completed => "completed",
45 Self::Cancelled => "cancelled",
46 Self::TimedOut => "timed_out",
47 Self::Interrupted => "interrupted",
48 }
49 }
50
51 fn event_kind(self) -> &'static str {
52 match self {
53 Self::Completed => "waitpoint_wait_completed",
54 Self::Cancelled => "waitpoint_wait_cancelled",
55 Self::TimedOut => "waitpoint_wait_timed_out",
56 Self::Interrupted => "waitpoint_wait_interrupted",
57 }
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct WaitpointRecord {
63 pub id: String,
64 pub status: WaitpointStatus,
65 pub created_at: String,
66 pub created_by: Option<String>,
67 pub completed_at: Option<String>,
68 pub completed_by: Option<String>,
69 pub cancelled_at: Option<String>,
70 pub cancelled_by: Option<String>,
71 pub reason: Option<String>,
72 #[serde(default)]
73 pub metadata: BTreeMap<String, serde_json::Value>,
74}
75
76impl WaitpointRecord {
77 pub fn open(
78 id: impl Into<String>,
79 created_by: Option<String>,
80 metadata: BTreeMap<String, serde_json::Value>,
81 ) -> Self {
82 Self {
83 id: id.into(),
84 status: WaitpointStatus::Open,
85 created_at: now_rfc3339(),
86 created_by,
87 completed_at: None,
88 completed_by: None,
89 cancelled_at: None,
90 cancelled_by: None,
91 reason: None,
92 metadata,
93 }
94 }
95
96 pub fn is_terminal(&self) -> bool {
97 matches!(
98 self.status,
99 WaitpointStatus::Completed | WaitpointStatus::Cancelled
100 )
101 }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct WaitpointWaitStartRecord {
106 pub wait_id: String,
107 pub waitpoint_ids: Vec<String>,
108 pub started_at: String,
109 pub trace_id: Option<String>,
110 pub replay_of_event_id: Option<String>,
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub struct WaitpointWaitRecord {
115 pub wait_id: String,
116 pub waitpoint_ids: Vec<String>,
117 pub status: WaitpointWaitStatus,
118 pub started_at: String,
119 pub resolved_at: String,
120 pub waitpoints: Vec<WaitpointRecord>,
121 pub cancelled_waitpoint_id: Option<String>,
122 pub trace_id: Option<String>,
123 pub replay_of_event_id: Option<String>,
124 pub reason: Option<String>,
125}
126
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum WaitpointResolution {
129 Pending,
130 Completed,
131 Cancelled { waitpoint_id: String },
132}
133
134pub fn dedupe_waitpoint_ids(ids: &[String]) -> Vec<String> {
135 let mut seen = BTreeSet::new();
136 let mut out = Vec::new();
137 for id in ids {
138 let trimmed = id.trim();
139 if trimmed.is_empty() {
140 continue;
141 }
142 if seen.insert(trimmed.to_string()) {
143 out.push(trimmed.to_string());
144 }
145 }
146 out
147}
148
149pub fn waitpoint_topic(id: &str) -> Result<Topic, LogError> {
150 Topic::new(format!(
151 "{WAITPOINT_STATE_TOPIC_PREFIX}{}",
152 sanitize_topic_component(id)
153 ))
154}
155
156pub fn waits_topic() -> Result<Topic, LogError> {
157 Topic::new(WAITPOINT_WAITS_TOPIC)
158}
159
160pub async fn load_waitpoint(
161 log: &Arc<AnyEventLog>,
162 id: &str,
163) -> Result<Option<WaitpointRecord>, LogError> {
164 let events = log
165 .read_range(&waitpoint_topic(id)?, None, usize::MAX)
166 .await?;
167 let mut latest = None;
168 for (_, event) in events {
169 if !matches!(
170 event.kind.as_str(),
171 "waitpoint_created" | "waitpoint_completed" | "waitpoint_cancelled"
172 ) {
173 continue;
174 }
175 let Ok(record) = serde_json::from_value::<WaitpointRecord>(event.payload) else {
176 continue;
177 };
178 latest = Some(record);
179 }
180 Ok(latest)
181}
182
183pub async fn load_waitpoints(
184 log: &Arc<AnyEventLog>,
185 ids: &[String],
186) -> Result<Vec<WaitpointRecord>, LogError> {
187 let mut out = Vec::new();
188 for id in dedupe_waitpoint_ids(ids) {
189 if let Some(record) = load_waitpoint(log, &id).await? {
190 out.push(record);
191 }
192 }
193 Ok(out)
194}
195
196pub fn resolve_waitpoints(ids: &[String], waitpoints: &[WaitpointRecord]) -> WaitpointResolution {
197 let mut by_id = BTreeMap::new();
198 for waitpoint in waitpoints {
199 by_id.insert(waitpoint.id.as_str(), waitpoint);
200 }
201 let ids = dedupe_waitpoint_ids(ids);
202 if ids.is_empty() {
203 return WaitpointResolution::Pending;
204 }
205
206 let mut all_completed = true;
207 for id in ids {
208 let Some(waitpoint) = by_id.get(id.as_str()) else {
209 all_completed = false;
210 continue;
211 };
212 match waitpoint.status {
213 WaitpointStatus::Completed => {}
214 WaitpointStatus::Cancelled => {
215 return WaitpointResolution::Cancelled {
216 waitpoint_id: waitpoint.id.clone(),
217 };
218 }
219 WaitpointStatus::Open => {
220 all_completed = false;
221 }
222 }
223 }
224
225 if all_completed {
226 WaitpointResolution::Completed
227 } else {
228 WaitpointResolution::Pending
229 }
230}
231
232pub async fn create_waitpoint(
233 log: &Arc<AnyEventLog>,
234 id: &str,
235 created_by: Option<String>,
236 metadata: BTreeMap<String, serde_json::Value>,
237) -> Result<WaitpointRecord, LogError> {
238 if let Some(existing) = load_waitpoint(log, id).await? {
239 return Ok(existing);
240 }
241 let record = WaitpointRecord::open(id, created_by, metadata);
242 append_waitpoint_state(log, "waitpoint_created", &record).await?;
243 Ok(record)
244}
245
246pub async fn complete_waitpoint(
247 log: &Arc<AnyEventLog>,
248 id: &str,
249 completed_by: Option<String>,
250) -> Result<WaitpointRecord, LogError> {
251 let existing = load_waitpoint(log, id).await?;
252 if let Some(existing) = existing.as_ref() {
253 if existing.is_terminal() {
254 return Ok(existing.clone());
255 }
256 }
257
258 let now = now_rfc3339();
259 let mut record = existing.unwrap_or_else(|| WaitpointRecord {
260 id: id.to_string(),
261 status: WaitpointStatus::Open,
262 created_at: now.clone(),
263 created_by: completed_by.clone(),
264 completed_at: None,
265 completed_by: None,
266 cancelled_at: None,
267 cancelled_by: None,
268 reason: None,
269 metadata: BTreeMap::new(),
270 });
271 record.status = WaitpointStatus::Completed;
272 record.completed_at = Some(now);
273 record.completed_by = completed_by;
274 record.cancelled_at = None;
275 record.cancelled_by = None;
276 record.reason = None;
277 append_waitpoint_state(log, "waitpoint_completed", &record).await?;
278 Ok(record)
279}
280
281pub async fn cancel_waitpoint(
282 log: &Arc<AnyEventLog>,
283 id: &str,
284 cancelled_by: Option<String>,
285 reason: Option<String>,
286) -> Result<WaitpointRecord, LogError> {
287 let existing = load_waitpoint(log, id).await?;
288 if let Some(existing) = existing.as_ref() {
289 if existing.is_terminal() {
290 return Ok(existing.clone());
291 }
292 }
293
294 let now = now_rfc3339();
295 let mut record = existing.unwrap_or_else(|| WaitpointRecord {
296 id: id.to_string(),
297 status: WaitpointStatus::Open,
298 created_at: now.clone(),
299 created_by: cancelled_by.clone(),
300 completed_at: None,
301 completed_by: None,
302 cancelled_at: None,
303 cancelled_by: None,
304 reason: None,
305 metadata: BTreeMap::new(),
306 });
307 record.status = WaitpointStatus::Cancelled;
308 record.completed_at = None;
309 record.completed_by = None;
310 record.cancelled_at = Some(now);
311 record.cancelled_by = cancelled_by;
312 record.reason = reason;
313 append_waitpoint_state(log, "waitpoint_cancelled", &record).await?;
314 Ok(record)
315}
316
317pub async fn append_wait_started(
318 log: &Arc<AnyEventLog>,
319 record: &WaitpointWaitStartRecord,
320) -> Result<(), LogError> {
321 log.append(
322 &waits_topic()?,
323 LogEvent::new(
324 "waitpoint_wait_started",
325 serde_json::to_value(record).map_err(|error| {
326 LogError::Serde(format!("waitpoint wait encode error: {error}"))
327 })?,
328 )
329 .with_headers(wait_headers(&record.wait_id, &record.waitpoint_ids)),
330 )
331 .await
332 .map(|_| ())
333}
334
335pub async fn append_wait_terminal(
336 log: &Arc<AnyEventLog>,
337 record: &WaitpointWaitRecord,
338) -> Result<(), LogError> {
339 log.append(
340 &waits_topic()?,
341 LogEvent::new(
342 record.status.event_kind(),
343 serde_json::to_value(record).map_err(|error| {
344 LogError::Serde(format!("waitpoint wait encode error: {error}"))
345 })?,
346 )
347 .with_headers(wait_headers(&record.wait_id, &record.waitpoint_ids)),
348 )
349 .await
350 .map(|_| ())
351}
352
353pub async fn find_wait_terminal(
354 log: &Arc<AnyEventLog>,
355 wait_id: &str,
356) -> Result<Option<WaitpointWaitRecord>, LogError> {
357 let events = log.read_range(&waits_topic()?, None, usize::MAX).await?;
358 let mut latest = None;
359 for (_, event) in events {
360 if !matches!(
361 event.kind.as_str(),
362 "waitpoint_wait_completed"
363 | "waitpoint_wait_cancelled"
364 | "waitpoint_wait_timed_out"
365 | "waitpoint_wait_interrupted"
366 ) {
367 continue;
368 }
369 if event.headers.get("wait_id").map(String::as_str) != Some(wait_id) {
370 continue;
371 }
372 let Ok(record) = serde_json::from_value::<WaitpointWaitRecord>(event.payload) else {
373 continue;
374 };
375 latest = Some(record);
376 }
377 Ok(latest)
378}
379
380async fn append_waitpoint_state(
381 log: &Arc<AnyEventLog>,
382 kind: &str,
383 record: &WaitpointRecord,
384) -> Result<(), LogError> {
385 log.append(
386 &waitpoint_topic(&record.id)?,
387 LogEvent::new(
388 kind,
389 serde_json::to_value(record)
390 .map_err(|error| LogError::Serde(format!("waitpoint encode error: {error}")))?,
391 )
392 .with_headers(waitpoint_headers(record)),
393 )
394 .await
395 .map(|_| ())
396}
397
398fn wait_headers(wait_id: &str, waitpoint_ids: &[String]) -> BTreeMap<String, String> {
399 let mut headers = BTreeMap::new();
400 headers.insert("wait_id".to_string(), wait_id.to_string());
401 headers.insert("waitpoints".to_string(), waitpoint_ids.join(","));
402 headers
403}
404
405fn waitpoint_headers(record: &WaitpointRecord) -> BTreeMap<String, String> {
406 let mut headers = BTreeMap::new();
407 headers.insert("waitpoint_id".to_string(), record.id.clone());
408 headers.insert("status".to_string(), record.status.as_str().to_string());
409 if let Some(created_by) = record.created_by.as_ref() {
410 headers.insert("created_by".to_string(), created_by.clone());
411 }
412 if let Some(completed_by) = record.completed_by.as_ref() {
413 headers.insert("completed_by".to_string(), completed_by.clone());
414 }
415 if let Some(cancelled_by) = record.cancelled_by.as_ref() {
416 headers.insert("cancelled_by".to_string(), cancelled_by.clone());
417 }
418 headers
419}
420
421fn now_rfc3339() -> String {
422 harn_clock::system_now_rfc3339()
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::event_log::{FileEventLog, MemoryEventLog};
431
432 #[tokio::test]
433 async fn waitpoint_state_persists_across_file_reopen() {
434 let dir = tempfile::tempdir().expect("tempdir");
435 let first = Arc::new(AnyEventLog::File(
436 FileEventLog::open(dir.path().to_path_buf(), 32).expect("open file log"),
437 ));
438 create_waitpoint(&first, "demo", Some("creator".to_string()), BTreeMap::new())
439 .await
440 .expect("create waitpoint");
441 complete_waitpoint(&first, "demo", Some("completer".to_string()))
442 .await
443 .expect("complete waitpoint");
444
445 let reopened = Arc::new(AnyEventLog::File(
446 FileEventLog::open(dir.path().to_path_buf(), 32).expect("reopen file log"),
447 ));
448 let state = load_waitpoint(&reopened, "demo")
449 .await
450 .expect("load state")
451 .expect("waitpoint exists");
452 assert_eq!(state.status, WaitpointStatus::Completed);
453 assert_eq!(state.completed_by.as_deref(), Some("completer"));
454 }
455
456 #[tokio::test]
457 async fn wait_terminal_lookup_returns_latest_terminal_record() {
458 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
459 append_wait_started(
460 &log,
461 &WaitpointWaitStartRecord {
462 wait_id: "wait-demo".to_string(),
463 waitpoint_ids: vec!["a".to_string(), "b".to_string()],
464 started_at: "2026-01-01T00:00:00Z".to_string(),
465 trace_id: Some("trace-demo".to_string()),
466 replay_of_event_id: None,
467 },
468 )
469 .await
470 .expect("append wait start");
471 append_wait_terminal(
472 &log,
473 &WaitpointWaitRecord {
474 wait_id: "wait-demo".to_string(),
475 waitpoint_ids: vec!["a".to_string(), "b".to_string()],
476 status: WaitpointWaitStatus::TimedOut,
477 started_at: "2026-01-01T00:00:00Z".to_string(),
478 resolved_at: "2026-01-01T00:01:00Z".to_string(),
479 waitpoints: Vec::new(),
480 cancelled_waitpoint_id: None,
481 trace_id: Some("trace-demo".to_string()),
482 replay_of_event_id: None,
483 reason: Some("deadline elapsed".to_string()),
484 },
485 )
486 .await
487 .expect("append wait result");
488
489 let record = find_wait_terminal(&log, "wait-demo")
490 .await
491 .expect("lookup wait result")
492 .expect("wait result exists");
493 assert_eq!(record.status, WaitpointWaitStatus::TimedOut);
494 assert_eq!(record.reason.as_deref(), Some("deadline elapsed"));
495 }
496}