1use std::time::{Duration, SystemTime};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::id::{EffectId, EffectKey, WorkerId};
7use crate::kind::EffectKind;
8use crate::state::{EffectStatus, InvalidTransition, Transition};
9use crate::{EffectEvent, ErrorRecord, Lease, NewEffect, StoreError, TransitionRequest};
10
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17pub struct EffectRecord {
18 pub id: EffectId,
20 pub key: EffectKey,
22 pub kind: EffectKind,
24 #[serde(default)]
27 pub execution_contract: Option<crate::ExecutionContract>,
28 #[serde(default = "initial_schema_version")]
30 pub input_schema_version: u32,
31 #[serde(default = "initial_schema_version")]
33 pub output_schema_version: u32,
34 pub status: EffectStatus,
36 pub input: Option<Value>,
38 pub input_fingerprint: Option<String>,
40 pub output: Option<Value>,
42 pub last_error: Option<ErrorRecord>,
44 pub created_by: Option<String>,
46 pub attempt_count: u32,
48 pub may_have_applied: bool,
53 pub compensation_attempts: u32,
55 pub approved: bool,
58 pub next_attempt_at: Option<SystemTime>,
60 pub attempt_started_at: Option<SystemTime>,
62 pub attempt_ended_at: Option<SystemTime>,
66 pub lease_owner: Option<WorkerId>,
68 pub lease_epoch: u64,
70 pub lease_expires_at: Option<SystemTime>,
72 pub version: u64,
74 pub created_at: SystemTime,
76 pub updated_at: SystemTime,
78 pub committed_at: Option<SystemTime>,
80}
81
82impl EffectRecord {
83 pub fn new(new: NewEffect) -> Self {
85 Self {
86 id: new.id,
87 key: new.key,
88 kind: new.kind,
89 execution_contract: new.execution_contract,
90 input_schema_version: new.input_schema_version,
91 output_schema_version: 1,
92 status: EffectStatus::Pending,
93 input: new.input,
94 input_fingerprint: new.input_fingerprint,
95 output: None,
96 last_error: None,
97 created_by: new.created_by,
98 attempt_count: 0,
99 may_have_applied: false,
100 compensation_attempts: 0,
101 approved: false,
102 next_attempt_at: None,
103 attempt_started_at: None,
104 attempt_ended_at: None,
105 lease_owner: None,
106 lease_epoch: 0,
107 lease_expires_at: None,
108 version: 0,
109 created_at: new.now,
110 updated_at: new.now,
111 committed_at: None,
112 }
113 }
114
115 pub fn live_lease_owner(&self, now: SystemTime) -> Option<&WorkerId> {
117 match (&self.lease_owner, self.lease_expires_at) {
118 (Some(owner), Some(expires_at)) if expires_at > now => Some(owner),
119 _ => None,
120 }
121 }
122
123 pub fn acquire_lease(
130 &mut self,
131 owner: &WorkerId,
132 now: SystemTime,
133 ttl: Duration,
134 ) -> Result<Lease, StoreError> {
135 if let Some(holder) = self.live_lease_owner(now) {
136 return Err(StoreError::LeaseHeld {
137 owner: holder.clone(),
138 expires_at: self.lease_expires_at.unwrap_or(now),
139 });
140 }
141 let expires_at = expiry(now, ttl);
142 self.lease_epoch += 1;
143 self.lease_owner = Some(owner.clone());
144 self.lease_expires_at = Some(expires_at);
145 Ok(Lease {
146 effect_id: self.id,
147 owner: owner.clone(),
148 epoch: self.lease_epoch,
149 expires_at,
150 })
151 }
152
153 pub fn renew_lease(
161 &mut self,
162 lease: &Lease,
163 now: SystemTime,
164 ttl: Duration,
165 ) -> Result<Lease, StoreError> {
166 self.check_lease(lease, now)?;
167 let expires_at = expiry(now, ttl);
168 self.lease_expires_at = Some(expires_at);
169 Ok(Lease {
170 expires_at,
171 ..lease.clone()
172 })
173 }
174
175 pub fn release_lease(&mut self, lease: &Lease) -> bool {
178 let current = lease.effect_id == self.id
179 && self.lease_epoch == lease.epoch
180 && self.lease_owner.as_ref() == Some(&lease.owner);
181 if current {
182 self.lease_owner = None;
183 self.lease_expires_at = None;
184 }
185 current
186 }
187
188 pub fn apply(&mut self, request: TransitionRequest) -> Result<EffectEvent, StoreError> {
223 let now = request.now;
224 match &request.lease {
225 Some(lease) => self.check_lease(lease, now)?,
226 None => {
227 if let Some(owner) = self.live_lease_owner(now) {
228 return Err(StoreError::LeaseHeld {
229 owner: owner.clone(),
230 expires_at: self.lease_expires_at.unwrap_or(now),
231 });
232 }
233 }
234 }
235 if request.expected_version != self.version {
236 return Err(StoreError::VersionConflict {
237 expected: request.expected_version,
238 actual: self.version,
239 });
240 }
241 let from = self.status;
242 let to = from.apply(request.transition)?;
243 if request.transition == Transition::FailedDefinitively
244 && self.may_have_applied
245 && self.kind != EffectKind::Read
246 {
247 return Err(InvalidTransition {
248 from,
249 event: request.transition,
250 }
251 .into());
252 }
253
254 match request.transition {
255 Transition::OutcomeUnknown => {
256 if request.next_attempt_at.is_some() {
257 self.next_attempt_at = self.next_attempt_at.max(request.next_attempt_at);
258 }
259 }
260 Transition::StartAttempt => {
261 self.attempt_count = self.attempt_count.saturating_add(1);
262 self.attempt_started_at = Some(now);
263 self.attempt_ended_at = None;
264 self.next_attempt_at = None;
265 self.output = None;
266 }
267 Transition::ScheduleRetry
268 | Transition::ResolvedRetry
269 | Transition::ScheduleCompensationRetry => {
270 self.next_attempt_at = Some(request.next_attempt_at.unwrap_or(now));
271 }
272 Transition::Approve => self.approved = true,
273 Transition::StartCompensation => {
274 self.compensation_attempts = 1;
275 self.next_attempt_at = None;
276 }
277 Transition::StartCompensationRetry => {
278 self.compensation_attempts = self.compensation_attempts.saturating_add(1);
279 self.next_attempt_at = None;
280 }
281 _ => {}
282 }
283 if from == EffectStatus::Executing {
284 self.attempt_ended_at = Some(now);
285 }
286 if to == EffectStatus::Committed {
287 self.committed_at = Some(now);
288 }
289 if to == EffectStatus::Unknown {
290 self.may_have_applied = true;
291 }
292 let shown_not_applied = matches!(
293 request.transition,
294 Transition::VerificationNotApplied | Transition::ResolvedNotApplied
295 ) || (from == EffectStatus::Verifying
296 && request.transition == Transition::ScheduleRetry);
297 if shown_not_applied {
298 self.may_have_applied = false;
299 }
300 if let Some(output) = request.output {
301 self.output = Some(output);
302 self.output_schema_version = request.output_schema_version.unwrap_or(1);
303 }
304 if let Some(error) = request.error {
305 self.last_error = Some(error);
306 }
307 self.status = to;
308 self.version += 1;
309 self.updated_at = now;
310
311 Ok(EffectEvent {
312 effect_id: self.id,
313 sequence: self.version,
314 transition: request.transition,
315 from,
316 to,
317 attempt: self.attempt_count,
318 actor: request.actor,
319 payload: request.payload,
320 at: now,
321 })
322 }
323
324 fn check_lease(&self, lease: &Lease, now: SystemTime) -> Result<(), StoreError> {
325 let valid = lease.effect_id == self.id
326 && self.lease_epoch == lease.epoch
327 && self.lease_owner.as_ref() == Some(&lease.owner)
328 && self
329 .lease_expires_at
330 .is_some_and(|expires_at| expires_at > now);
331 if valid {
332 Ok(())
333 } else {
334 Err(StoreError::LeaseLost)
335 }
336 }
337}
338
339fn initial_schema_version() -> u32 {
340 1
341}
342
343fn expiry(now: SystemTime, ttl: Duration) -> SystemTime {
346 now.checked_add(ttl).unwrap_or(now)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::id::{EffectName, LogicalKey};
353
354 const TTL: Duration = Duration::from_secs(30);
355
356 fn t(secs: u64) -> SystemTime {
357 SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + secs)
358 }
359
360 fn record() -> EffectRecord {
361 let key = EffectKey::new(
362 EffectName::new("payment.charge").unwrap(),
363 LogicalKey::new("order_1").unwrap(),
364 );
365 EffectRecord::new(NewEffect::new(key, EffectKind::IrreversibleWrite, t(0)))
366 }
367
368 fn worker(name: &str) -> WorkerId {
369 WorkerId::new(name)
370 }
371
372 #[test]
373 fn failed_apply_leaves_the_record_unchanged() {
374 let mut rec = record();
375 let lease = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
376 let before = rec.clone();
377
378 let mut bad = TransitionRequest::new(&rec, Some(&lease), Transition::Succeeded, t(1));
379 bad.output = Some(Value::from(1));
380 assert!(matches!(
381 rec.apply(bad),
382 Err(StoreError::InvalidTransition(_))
383 ));
384 assert_eq!(rec, before);
385 }
386
387 #[test]
388 fn stale_epoch_is_fenced_off_after_takeover() {
389 let mut rec = record();
390 let old = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
391 let new = rec.acquire_lease(&worker("b"), t(30), TTL).unwrap();
392 assert_eq!(new.epoch, old.epoch + 1);
393
394 let request = TransitionRequest::new(&rec, Some(&old), Transition::StartAttempt, t(31));
395 assert!(matches!(rec.apply(request), Err(StoreError::LeaseLost)));
396 assert!(
397 !rec.release_lease(&old),
398 "stale release must not clear b's lease"
399 );
400 assert_eq!(rec.live_lease_owner(t(31)), Some(&worker("b")));
401 }
402
403 #[test]
404 fn same_owner_cannot_double_acquire() {
405 let mut rec = record();
406 rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
407 assert!(matches!(
408 rec.acquire_lease(&worker("a"), t(1), TTL),
409 Err(StoreError::LeaseHeld { .. })
410 ));
411 }
412
413 #[test]
414 fn overflowing_ttl_fails_safe() {
415 let mut rec = record();
416 let lease = rec
417 .acquire_lease(&worker("a"), t(0), Duration::MAX)
418 .unwrap();
419 assert_eq!(lease.expires_at, t(0));
420 assert_eq!(rec.live_lease_owner(t(0)), None);
421 }
422
423 #[test]
424 fn a_failed_attempt_cannot_fail_an_effect_that_may_have_applied() {
425 let mut rec = record();
426 let lease = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
427 for transition in [
428 Transition::StartAttempt,
429 Transition::OutcomeUnknown,
430 Transition::ScheduleRetry,
431 Transition::StartAttempt,
432 ] {
433 rec.apply(TransitionRequest::new(&rec, Some(&lease), transition, t(1)))
434 .unwrap();
435 }
436 assert!(
437 rec.may_have_applied,
438 "the unknown outcome is remembered across retries"
439 );
440 let before = rec.clone();
441 let fail = TransitionRequest::new(&rec, Some(&lease), Transition::FailedDefinitively, t(2));
442 assert!(matches!(
443 rec.apply(fail),
444 Err(StoreError::InvalidTransition(_))
445 ));
446 assert_eq!(rec, before);
447
448 for transition in [
450 Transition::StartVerification,
451 Transition::ScheduleRetry,
452 Transition::StartAttempt,
453 ] {
454 rec.apply(TransitionRequest::new(&rec, Some(&lease), transition, t(3)))
455 .unwrap();
456 }
457 assert!(!rec.may_have_applied);
458 rec.apply(TransitionRequest::new(
459 &rec,
460 Some(&lease),
461 Transition::FailedDefinitively,
462 t(4),
463 ))
464 .unwrap();
465 assert_eq!(rec.status, EffectStatus::Failed);
466 }
467
468 #[test]
469 fn bookkeeping_follows_transitions() {
470 let mut rec = record();
471 let lease = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
472
473 let event = rec
474 .apply(TransitionRequest::new(
475 &rec,
476 Some(&lease),
477 Transition::StartAttempt,
478 t(1),
479 ))
480 .unwrap();
481 assert_eq!((event.sequence, event.attempt), (1, 1));
482 assert_eq!(rec.attempt_started_at, Some(t(1)));
483
484 let mut retry = TransitionRequest::new(&rec, Some(&lease), Transition::ScheduleRetry, t(2));
485 retry.next_attempt_at = Some(t(10));
486 retry.error = Some(ErrorRecord {
487 class: Some(crate::FailureClass::Transient),
488 message: "connection refused".into(),
489 });
490 rec.apply(retry).unwrap();
491 assert_eq!(rec.next_attempt_at, Some(t(10)));
492 assert_eq!(rec.attempt_ended_at, Some(t(2)));
493 assert!(rec.last_error.is_some());
494
495 rec.apply(TransitionRequest::new(
496 &rec,
497 Some(&lease),
498 Transition::StartAttempt,
499 t(10),
500 ))
501 .unwrap();
502 assert_eq!(rec.attempt_count, 2);
503 assert_eq!(rec.next_attempt_at, None);
504 assert_eq!(rec.attempt_ended_at, None);
505
506 let mut done = TransitionRequest::new(&rec, Some(&lease), Transition::Succeeded, t(11));
507 done.output = Some(serde_json::json!({ "payment": "pi_1" }));
508 let event = rec.apply(done).unwrap();
509 assert_eq!(event.to, EffectStatus::Committed);
510 assert_eq!(rec.committed_at, Some(t(11)));
511 assert_eq!(rec.version, 4);
512 }
513}