1use std::path::Path;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::time::Duration;
17
18use lash_core::{
19 AwaitEventResolver, DurabilityTier, EffectHost, ExecutionScope, LeaseTimings,
20 RuntimeEffectCommand, RuntimeEffectController, RuntimeEffectControllerError,
21 RuntimeEffectEnvelope, RuntimeEffectLocalExecutor, RuntimeEffectOutcome, RuntimeError,
22 ScopedEffectController,
23};
24
25use super::*;
26
27const STATUS_IN_PROGRESS: &str = "in_progress";
28const STATUS_COMPLETED: &str = "completed";
29const STATUS_FAILED: &str = "failed";
30const BUSY_POLL: Duration = Duration::from_millis(25);
31
32static EFFECT_OWNER_COUNTER: AtomicU64 = AtomicU64::new(1);
33
34#[derive(Clone, Debug, Default)]
36pub struct SqliteEffectReplayOptions {
37 pub lease_timings: LeaseTimings,
41}
42
43struct SqliteEffectReplayInner {
44 conn: SqliteConnection,
45 owner_id: String,
46 lease_counter: AtomicU64,
47 replay_mode: AtomicBool,
48 lease_timings: LeaseTimings,
49}
50
51#[derive(Clone)]
57pub struct SqliteEffectHost {
58 inner: Arc<SqliteEffectReplayInner>,
59}
60
61#[derive(Clone)]
63pub struct SqliteRuntimeEffectController {
64 inner: Arc<SqliteEffectReplayInner>,
65 scope: ExecutionScope,
66}
67
68struct ClaimedEffect {
69 scope_id: String,
70 replay_key: String,
71 envelope_hash: String,
72 lease_token: String,
73 due_at_ms: Option<u64>,
74}
75
76enum PreparedEffect {
77 ReplayOutcome {
78 outcome: Box<RuntimeEffectOutcome>,
79 due_at_ms: Option<u64>,
80 },
81 ReplayError(RuntimeEffectControllerError),
82 Claimed(ClaimedEffect),
83 Busy {
84 retry_at_ms: u64,
85 },
86}
87
88impl SqliteEffectHost {
89 pub async fn open(path: &Path) -> tokio_rusqlite::Result<Self> {
90 Self::open_with_options(path, SqliteEffectReplayOptions::default()).await
91 }
92
93 pub async fn open_with_options(
94 path: &Path,
95 options: SqliteEffectReplayOptions,
96 ) -> tokio_rusqlite::Result<Self> {
97 Ok(Self {
98 inner: open_effect_replay_inner(path, StoreBacking::File, options).await?,
99 })
100 }
101
102 pub async fn memory() -> tokio_rusqlite::Result<Self> {
103 Self::memory_with_options(SqliteEffectReplayOptions::default()).await
104 }
105
106 pub async fn memory_with_options(
107 options: SqliteEffectReplayOptions,
108 ) -> tokio_rusqlite::Result<Self> {
109 Ok(Self {
110 inner: open_effect_replay_memory_inner(options).await?,
111 })
112 }
113
114 pub fn start_replay(&self) {
117 self.inner.replay_mode.store(true, Ordering::SeqCst);
118 }
119}
120
121impl AwaitEventResolver for SqliteEffectHost {
122 fn durability_tier(&self) -> DurabilityTier {
123 DurabilityTier::Durable
124 }
125}
126
127impl EffectHost for SqliteEffectHost {
128 fn scoped<'run>(
129 &'run self,
130 scope: ExecutionScope,
131 ) -> Result<ScopedEffectController<'run>, RuntimeError> {
132 let controller = SqliteRuntimeEffectController {
133 inner: Arc::clone(&self.inner),
134 scope: scope.clone(),
135 };
136 ScopedEffectController::shared(Arc::new(controller), scope)
137 }
138
139 fn scoped_static(
140 &self,
141 scope: ExecutionScope,
142 ) -> Result<Option<ScopedEffectController<'static>>, RuntimeError> {
143 let controller = SqliteRuntimeEffectController {
144 inner: Arc::clone(&self.inner),
145 scope: scope.clone(),
146 };
147 Ok(Some(ScopedEffectController::shared(
148 Arc::new(controller),
149 scope,
150 )?))
151 }
152}
153
154impl SqliteRuntimeEffectController {
155 pub async fn open(path: &Path, scope: ExecutionScope) -> tokio_rusqlite::Result<Self> {
156 Self::open_with_options(path, scope, SqliteEffectReplayOptions::default()).await
157 }
158
159 pub async fn open_with_options(
160 path: &Path,
161 scope: ExecutionScope,
162 options: SqliteEffectReplayOptions,
163 ) -> tokio_rusqlite::Result<Self> {
164 Ok(Self {
165 inner: open_effect_replay_inner(path, StoreBacking::File, options).await?,
166 scope,
167 })
168 }
169
170 pub async fn memory(scope: ExecutionScope) -> tokio_rusqlite::Result<Self> {
171 Self::memory_with_options(scope, SqliteEffectReplayOptions::default()).await
172 }
173
174 pub async fn memory_with_options(
175 scope: ExecutionScope,
176 options: SqliteEffectReplayOptions,
177 ) -> tokio_rusqlite::Result<Self> {
178 Ok(Self {
179 inner: open_effect_replay_memory_inner(options).await?,
180 scope,
181 })
182 }
183
184 pub fn start_replay(&self) {
187 self.inner.replay_mode.store(true, Ordering::SeqCst);
188 }
189
190 async fn prepare_effect(
191 &self,
192 envelope: &RuntimeEffectEnvelope,
193 ) -> Result<PreparedEffect, RuntimeEffectControllerError> {
194 let replay_key = envelope
195 .invocation
196 .replay_key()
197 .ok_or_else(|| {
198 RuntimeEffectControllerError::new(
199 "sqlite_effect_replay_key_missing",
200 "runtime effect envelope requires replay.key",
201 )
202 })?
203 .to_string();
204 let envelope_hash = envelope.stable_hash()?;
205 let scope_id = self.scope.id().to_string();
206 let now = current_epoch_ms();
207 let lease_token = self.inner.next_lease_token();
208 let due_at_ms = sleep_due_at_ms(envelope, now);
209 let lease_ttl_ms = self.inner.lease_timings.ttl_ms();
210 let lease_expires_at_ms = now.saturating_add(lease_ttl_ms);
211 let replay_mode = self.inner.replay_mode.load(Ordering::SeqCst);
212 let owner_id = self.inner.owner_id.clone();
213
214 let outcome: Result<PreparedEffect, RuntimeEffectControllerError> = self
222 .inner
223 .conn
224 .write(move |tx| {
225 let row = tx
226 .query_row(
227 "SELECT envelope_hash, status, outcome_json, error_json,
228 lease_owner_id, lease_token, lease_expires_at_ms, due_at_ms
229 FROM runtime_effect_replay
230 WHERE scope_id = ?1 AND replay_key = ?2",
231 params![scope_id.as_str(), replay_key.as_str()],
232 |row| {
233 Ok((
234 row.get::<_, String>(0)?,
235 row.get::<_, String>(1)?,
236 row.get::<_, Option<String>>(2)?,
237 row.get::<_, Option<String>>(3)?,
238 row.get::<_, i64>(6)?,
239 row.get::<_, Option<i64>>(7)?,
240 ))
241 },
242 )
243 .optional()?;
244
245 let Some((
246 existing_hash,
247 status,
248 outcome_json,
249 error_json,
250 lease_expires_row,
251 existing_due_row,
252 )) = row
253 else {
254 if replay_mode {
255 return Ok(Err(RuntimeEffectControllerError::new(
256 "sqlite_effect_replay_missing",
257 format!(
258 "no recorded runtime effect for scope `{scope_id}` and replay key `{replay_key}`"
259 ),
260 )));
261 }
262 let due_at_param = due_at_ms.map(|value| value as i64);
263 tx.execute(
264 "INSERT INTO runtime_effect_replay (
265 scope_id, replay_key, envelope_hash, status, outcome_json,
266 error_json, lease_owner_id, lease_token, lease_expires_at_ms,
267 due_at_ms, created_at_ms, updated_at_ms
268 )
269 VALUES (?1, ?2, ?3, ?4, NULL, NULL, ?5, ?6, ?7, ?8, ?9, ?10)",
270 params![
271 scope_id.as_str(),
272 replay_key.as_str(),
273 envelope_hash.as_str(),
274 STATUS_IN_PROGRESS,
275 owner_id.as_str(),
276 lease_token.as_str(),
277 lease_expires_at_ms as i64,
278 due_at_param,
279 now as i64,
280 now as i64,
281 ],
282 )?;
283 return Ok(Ok(PreparedEffect::Claimed(ClaimedEffect {
284 scope_id,
285 replay_key,
286 envelope_hash,
287 lease_token,
288 due_at_ms,
289 })));
290 };
291
292 if existing_hash != envelope_hash {
293 return Ok(Err(RuntimeEffectControllerError::new(
294 "sqlite_effect_replay_hash_conflict",
295 format!(
296 "runtime effect replay key `{replay_key}` in scope `{scope_id}` was reused with a different envelope hash"
297 ),
298 )));
299 }
300
301 let lease_expires_at_ms = lease_expires_row as u64;
302 let existing_due_at_ms = existing_due_row.map(|value| value as u64);
303
304 match status.as_str() {
305 STATUS_COMPLETED => {
306 let Some(json) = outcome_json else {
307 return Ok(Err(RuntimeEffectControllerError::new(
308 "sqlite_effect_replay_corrupt_row",
309 "completed runtime effect row is missing outcome_json",
310 )));
311 };
312 let outcome = match serde_json::from_str(&json) {
313 Ok(outcome) => outcome,
314 Err(err) => return Ok(Err(effect_decode_error(err))),
315 };
316 Ok(Ok(PreparedEffect::ReplayOutcome {
317 outcome: Box::new(outcome),
318 due_at_ms: existing_due_at_ms,
319 }))
320 }
321 STATUS_FAILED => {
322 let Some(json) = error_json else {
323 return Ok(Err(RuntimeEffectControllerError::new(
324 "sqlite_effect_replay_corrupt_row",
325 "failed runtime effect row is missing error_json",
326 )));
327 };
328 let err = match serde_json::from_str(&json) {
329 Ok(err) => err,
330 Err(err) => return Ok(Err(effect_decode_error(err))),
331 };
332 Ok(Ok(PreparedEffect::ReplayError(err)))
333 }
334 STATUS_IN_PROGRESS if lease_expires_at_ms > now => Ok(Ok(PreparedEffect::Busy {
335 retry_at_ms: lease_expires_at_ms,
336 })),
337 STATUS_IN_PROGRESS => {
338 let due_at_ms = existing_due_at_ms.or(due_at_ms);
339 let due_at_param = due_at_ms.map(|value| value as i64);
340 tx.execute(
341 "UPDATE runtime_effect_replay
342 SET lease_owner_id = ?3,
343 lease_token = ?4,
344 lease_expires_at_ms = ?5,
345 due_at_ms = ?6,
346 updated_at_ms = ?7
347 WHERE scope_id = ?1 AND replay_key = ?2",
348 params![
349 scope_id.as_str(),
350 replay_key.as_str(),
351 owner_id.as_str(),
352 lease_token.as_str(),
353 current_epoch_ms().saturating_add(lease_ttl_ms) as i64,
354 due_at_param,
355 current_epoch_ms() as i64,
356 ],
357 )?;
358 Ok(Ok(PreparedEffect::Claimed(ClaimedEffect {
359 scope_id,
360 replay_key,
361 envelope_hash,
362 lease_token,
363 due_at_ms,
364 })))
365 }
366 other => Ok(Err(RuntimeEffectControllerError::new(
367 "sqlite_effect_replay_corrupt_row",
368 format!("unknown runtime effect replay status `{other}`"),
369 ))),
370 }
371 })
372 .await
373 .map_err(effect_sqlite_error)?;
374 outcome
375 }
376
377 async fn finalize_effect(
378 &self,
379 claim: &ClaimedEffect,
380 outcome: &Result<RuntimeEffectOutcome, RuntimeEffectControllerError>,
381 ) -> Result<(), RuntimeEffectControllerError> {
382 let (status, outcome_json, error_json) = match outcome {
383 Ok(outcome) => (
384 STATUS_COMPLETED,
385 Some(serde_json::to_string(outcome).map_err(effect_encode_error)?),
386 None,
387 ),
388 Err(err) => (
389 STATUS_FAILED,
390 None,
391 Some(serde_json::to_string(err).map_err(effect_encode_error)?),
392 ),
393 };
394 let now = current_epoch_ms();
395 let scope_id = claim.scope_id.clone();
396 let replay_key = claim.replay_key.clone();
397 let envelope_hash = claim.envelope_hash.clone();
398 let owner_id = self.inner.owner_id.clone();
399 let lease_token = claim.lease_token.clone();
400 let status = status.to_string();
401
402 let result: Result<(), RuntimeEffectControllerError> = self
403 .inner
404 .conn
405 .write(move |tx| {
406 let changed = tx.execute(
407 "UPDATE runtime_effect_replay
408 SET status = ?6,
409 outcome_json = ?7,
410 error_json = ?8,
411 lease_owner_id = NULL,
412 lease_token = NULL,
413 lease_expires_at_ms = 0,
414 updated_at_ms = ?9
415 WHERE scope_id = ?1
416 AND replay_key = ?2
417 AND envelope_hash = ?3
418 AND lease_owner_id = ?4
419 AND lease_token = ?5
420 AND status = 'in_progress'
421 AND lease_expires_at_ms > ?10",
422 params![
423 scope_id.as_str(),
424 replay_key.as_str(),
425 envelope_hash.as_str(),
426 owner_id.as_str(),
427 lease_token.as_str(),
428 status.as_str(),
429 outcome_json,
430 error_json,
431 now as i64,
432 now as i64,
433 ],
434 )?;
435 if changed != 1 {
436 return Ok(Err(RuntimeEffectControllerError::new(
437 "sqlite_effect_replay_lease_lost",
438 format!(
439 "runtime effect replay lease was lost before finalizing scope `{scope_id}` replay key `{replay_key}`"
440 ),
441 )));
442 }
443 Ok(Ok(()))
444 })
445 .await
446 .map_err(effect_sqlite_error)?;
447 result
448 }
449
450 async fn renew_effect_lease(
451 &self,
452 claim: &ClaimedEffect,
453 ) -> Result<(), RuntimeEffectControllerError> {
454 let now = current_epoch_ms();
455 let renewed_expires_at = now.saturating_add(self.inner.lease_timings.ttl_ms());
456 let scope_id = claim.scope_id.clone();
457 let replay_key = claim.replay_key.clone();
458 let envelope_hash = claim.envelope_hash.clone();
459 let owner_id = self.inner.owner_id.clone();
460 let lease_token = claim.lease_token.clone();
461
462 let result: Result<(), RuntimeEffectControllerError> = self
463 .inner
464 .conn
465 .write(move |tx| {
466 let changed = tx.execute(
467 "UPDATE runtime_effect_replay
468 SET lease_expires_at_ms = ?6,
469 updated_at_ms = ?7
470 WHERE scope_id = ?1
471 AND replay_key = ?2
472 AND envelope_hash = ?3
473 AND lease_owner_id = ?4
474 AND lease_token = ?5
475 AND status = 'in_progress'
476 AND lease_expires_at_ms > ?8",
477 params![
478 scope_id.as_str(),
479 replay_key.as_str(),
480 envelope_hash.as_str(),
481 owner_id.as_str(),
482 lease_token.as_str(),
483 renewed_expires_at as i64,
484 now as i64,
485 now as i64,
486 ],
487 )?;
488 if changed != 1 {
489 return Ok(Err(RuntimeEffectControllerError::new(
490 "sqlite_effect_replay_lease_lost",
491 format!(
492 "runtime effect replay lease was lost while executing scope `{scope_id}` replay key `{replay_key}`"
493 ),
494 )));
495 }
496 Ok(Ok(()))
497 })
498 .await
499 .map_err(effect_sqlite_error)?;
500 result
501 }
502
503 async fn execute_claimed_effect_with_renewal(
504 &self,
505 claim: &ClaimedEffect,
506 envelope: RuntimeEffectEnvelope,
507 local_executor: RuntimeEffectLocalExecutor<'_>,
508 ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
509 let renew_every = self.inner.lease_timings.renew_interval();
510 let effect = self.execute_claimed_effect(claim, envelope, local_executor);
511 tokio::pin!(effect);
512 let renew_sleep = tokio::time::sleep(renew_every);
513 tokio::pin!(renew_sleep);
514
515 loop {
516 tokio::select! {
517 result = &mut effect => return result,
518 _ = &mut renew_sleep => {
519 self.renew_effect_lease(claim).await?;
520 renew_sleep.as_mut().reset(tokio::time::Instant::now() + renew_every);
521 }
522 }
523 }
524 }
525
526 async fn execute_claimed_effect(
527 &self,
528 claim: &ClaimedEffect,
529 envelope: RuntimeEffectEnvelope,
530 local_executor: RuntimeEffectLocalExecutor<'_>,
531 ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
532 if matches!(envelope.command, RuntimeEffectCommand::Sleep { .. }) {
533 sleep_until_due(claim.due_at_ms).await;
534 return Ok(RuntimeEffectOutcome::Sleep);
535 }
536 match envelope.command {
537 RuntimeEffectCommand::Process { command } => {
538 let result = local_executor.into_process()?.execute(*command).await?;
539 Ok(RuntimeEffectOutcome::Process { result })
540 }
541 _ => local_executor.execute(envelope).await,
542 }
543 }
544}
545
546impl AwaitEventResolver for SqliteRuntimeEffectController {
547 fn durability_tier(&self) -> DurabilityTier {
548 DurabilityTier::Durable
549 }
550
551 fn supports_durable_effects(&self) -> bool {
552 true
553 }
554}
555
556#[async_trait::async_trait]
557impl RuntimeEffectController for SqliteRuntimeEffectController {
558 async fn execute_effect(
559 &self,
560 envelope: RuntimeEffectEnvelope,
561 local_executor: RuntimeEffectLocalExecutor<'_>,
562 ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
563 loop {
564 match self.prepare_effect(&envelope).await? {
565 PreparedEffect::ReplayOutcome { outcome, due_at_ms } => {
566 sleep_until_due(due_at_ms).await;
567 return Ok(*outcome);
568 }
569 PreparedEffect::ReplayError(err) => return Err(err),
570 PreparedEffect::Claimed(claim) => {
571 let result = self
572 .execute_claimed_effect_with_renewal(&claim, envelope, local_executor)
573 .await;
574 let finalize = self.finalize_effect(&claim, &result).await;
575 return match (result, finalize) {
576 (Ok(outcome), Ok(())) => Ok(outcome),
577 (Err(err), Ok(())) => Err(err),
578 (_, Err(err)) => Err(err),
579 };
580 }
581 PreparedEffect::Busy { retry_at_ms } => {
582 sleep_until_retry(retry_at_ms).await;
583 }
584 }
585 }
586 }
587}
588
589async fn open_effect_replay_inner(
590 path: &Path,
591 backing: StoreBacking,
592 options: SqliteEffectReplayOptions,
593) -> tokio_rusqlite::Result<Arc<SqliteEffectReplayInner>> {
594 let conn = SqliteConnection::open(path).await?;
595 ensure_effect_schema(&conn).await?;
596 apply_pragmas(&conn, backing).await?;
597 Ok(Arc::new(SqliteEffectReplayInner::new(conn, options)))
598}
599
600async fn open_effect_replay_memory_inner(
601 options: SqliteEffectReplayOptions,
602) -> tokio_rusqlite::Result<Arc<SqliteEffectReplayInner>> {
603 let conn = SqliteConnection::open_in_memory().await?;
604 ensure_effect_schema(&conn).await?;
605 apply_pragmas(&conn, StoreBacking::Memory).await?;
606 Ok(Arc::new(SqliteEffectReplayInner::new(conn, options)))
607}
608
609impl SqliteEffectReplayInner {
610 fn new(conn: SqliteConnection, options: SqliteEffectReplayOptions) -> Self {
611 let sequence = EFFECT_OWNER_COUNTER.fetch_add(1, Ordering::SeqCst);
612 Self {
613 conn,
614 owner_id: format!(
615 "pid{}-{sequence}-{}",
616 std::process::id(),
617 current_epoch_ms()
618 ),
619 lease_counter: AtomicU64::new(1),
620 replay_mode: AtomicBool::new(false),
621 lease_timings: options.lease_timings,
622 }
623 }
624
625 fn next_lease_token(&self) -> String {
626 let sequence = self.lease_counter.fetch_add(1, Ordering::SeqCst);
627 format!("{}:{sequence}", self.owner_id)
628 }
629}
630
631fn sleep_due_at_ms(envelope: &RuntimeEffectEnvelope, now: u64) -> Option<u64> {
632 match envelope.command {
633 RuntimeEffectCommand::Sleep { duration_ms } => Some(now.saturating_add(duration_ms)),
634 _ => None,
635 }
636}
637
638async fn sleep_until_due(due_at_ms: Option<u64>) {
639 let Some(due_at_ms) = due_at_ms else {
640 return;
641 };
642 let now = current_epoch_ms();
643 if due_at_ms > now {
644 tokio::time::sleep(Duration::from_millis(due_at_ms - now)).await;
645 }
646}
647
648async fn sleep_until_retry(retry_at_ms: u64) {
649 let now = current_epoch_ms();
650 let delay = if retry_at_ms > now {
651 Duration::from_millis(retry_at_ms - now).min(BUSY_POLL)
652 } else {
653 BUSY_POLL
654 };
655 tokio::time::sleep(delay).await;
656}
657
658fn effect_sqlite_error(err: rusqlite::Error) -> RuntimeEffectControllerError {
659 RuntimeEffectControllerError::new("sqlite_effect_replay_store", err.to_string())
660}
661
662fn effect_encode_error(err: serde_json::Error) -> RuntimeEffectControllerError {
663 RuntimeEffectControllerError::new(
664 "sqlite_effect_replay_encode",
665 format!("failed to encode runtime effect replay row: {err}"),
666 )
667}
668
669fn effect_decode_error(err: serde_json::Error) -> RuntimeEffectControllerError {
670 RuntimeEffectControllerError::new(
671 "sqlite_effect_replay_decode",
672 format!("failed to decode runtime effect replay row: {err}"),
673 )
674}