1use std::sync::Arc;
2
3use tokio_util::sync::CancellationToken;
4
5use super::effect::ProcessRunner;
6use super::session_manager::RuntimeSessionServices;
7use super::{EmbeddedRuntimeBuilder, ProcessWorkDriver, QueuedWorkDriver, RuntimeHostConfig};
8use crate::{
9 InMemorySessionStore, LashRuntime, PluginError, PluginFactory, PluginHost, PluginStack,
10 ProcessAwaitOutput, ProcessExecutionContext, ProcessInput, ProcessLease,
11 ProcessLeaseCompletion, ProcessRecord, ProcessRegistration, ProcessRegistry,
12 SessionStoreFactory,
13};
14
15#[derive(Clone)]
21pub struct DurableProcessWorkerConfig {
22 pub plugin_host: Arc<PluginHost>,
23 pub runtime_host: RuntimeHostConfig,
24 pub session_policy: crate::SessionPolicy,
25 pub session_store_factory: Arc<dyn SessionStoreFactory>,
26 pub process_registry: Arc<dyn ProcessRegistry>,
27 pub trigger_store: Arc<dyn crate::TriggerStore>,
28 pub process_work_driver: Option<ProcessWorkDriver>,
29 pub queued_work_driver: Option<QueuedWorkDriver>,
30 #[doc(hidden)]
31 pub turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
32 pub residency: crate::Residency,
38 pub lease_owner: crate::LeaseOwnerIdentity,
51}
52
53impl DurableProcessWorkerConfig {
54 pub fn new(
55 plugin_host: Arc<PluginHost>,
56 runtime_host: RuntimeHostConfig,
57 session_store_factory: Arc<dyn SessionStoreFactory>,
58 process_registry: Arc<dyn ProcessRegistry>,
59 ) -> Self {
60 let clock = Arc::clone(&runtime_host.clock);
61 Self {
62 plugin_host,
63 runtime_host,
64 session_policy: crate::SessionPolicy::default(),
65 session_store_factory,
66 process_registry,
67 trigger_store: Arc::new(crate::InMemoryTriggerStore::with_clock(clock)),
68 process_work_driver: None,
69 queued_work_driver: None,
70 turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot::default(),
71 residency: crate::Residency::default(),
72 lease_owner: crate::LeaseOwnerIdentity::opaque(
73 format!("durable-process-worker:{}", uuid::Uuid::new_v4()),
74 uuid::Uuid::new_v4().to_string(),
75 ),
76 }
77 }
78
79 pub fn with_trigger_store(mut self, store: Arc<dyn crate::TriggerStore>) -> Self {
80 self.trigger_store = store;
81 self
82 }
83
84 pub fn with_session_policy(mut self, policy: crate::SessionPolicy) -> Self {
85 self.session_policy = policy;
86 self
87 }
88
89 pub fn with_residency(mut self, residency: crate::Residency) -> Self {
90 self.residency = residency;
91 self
92 }
93
94 pub fn with_process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
95 self.process_work_driver = Some(driver);
96 self
97 }
98
99 pub fn with_lease_owner(mut self, lease_owner: crate::LeaseOwnerIdentity) -> Self {
102 self.lease_owner = lease_owner;
103 self
104 }
105
106 pub fn with_queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
107 self.queued_work_driver = Some(driver);
108 self
109 }
110
111 #[doc(hidden)]
112 pub fn with_turn_phase_probe_slot(
113 mut self,
114 slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
115 ) -> Self {
116 self.turn_phase_probe_slot = slot;
117 self
118 }
119
120 pub fn from_plugin_factories(
121 plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
122 runtime_host: RuntimeHostConfig,
123 session_store_factory: Arc<dyn SessionStoreFactory>,
124 process_registry: Arc<dyn ProcessRegistry>,
125 ) -> Self {
126 Self::new(
127 Arc::new(PluginHost::new(plugin_factories.into_iter().collect())),
128 runtime_host,
129 session_store_factory,
130 process_registry,
131 )
132 }
133
134 pub fn from_plugin_stack(
135 plugin_stack: PluginStack,
136 runtime_host: RuntimeHostConfig,
137 session_store_factory: Arc<dyn SessionStoreFactory>,
138 process_registry: Arc<dyn ProcessRegistry>,
139 ) -> Self {
140 Self::from_plugin_factories(
141 plugin_stack.into_factories(),
142 runtime_host,
143 session_store_factory,
144 process_registry,
145 )
146 }
147}
148
149#[derive(Clone)]
151pub struct DurableProcessWorker {
152 config: Arc<DurableProcessWorkerConfig>,
153}
154
155enum RecoverFailure {
157 LeaseLost(PluginError),
161 Run(PluginError),
164}
165
166impl DurableProcessWorker {
167 pub fn new(config: DurableProcessWorkerConfig) -> Self {
168 Self {
169 config: Arc::new(config),
170 }
171 }
172
173 pub fn from_shared_config(config: Arc<DurableProcessWorkerConfig>) -> Self {
174 Self { config }
175 }
176
177 pub fn config(&self) -> &DurableProcessWorkerConfig {
178 &self.config
179 }
180
181 pub async fn run_process(
182 &self,
183 registration: ProcessRegistration,
184 execution_context: ProcessExecutionContext,
185 cancellation: CancellationToken,
186 ) -> Result<ProcessAwaitOutput, PluginError> {
187 let scoped_effect_controller = self
188 .config
189 .runtime_host
190 .control
191 .effect_host
192 .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
193 .map_err(|err| PluginError::Session(err.to_string()))?
194 .ok_or_else(|| {
195 PluginError::Session(
196 "process worker effect host must provide a static process scope".to_string(),
197 )
198 })?;
199 self.run_process_with_scoped_effect_controller(
200 registration,
201 execution_context,
202 scoped_effect_controller,
203 cancellation,
204 )
205 .await
206 }
207
208 pub async fn run_process_with_scoped_effect_controller(
209 &self,
210 registration: ProcessRegistration,
211 execution_context: ProcessExecutionContext,
212 scoped_effect_controller: crate::ScopedEffectController<'_>,
213 cancellation: CancellationToken,
214 ) -> Result<ProcessAwaitOutput, PluginError> {
215 self.ensure_stable_process_id(®istration)?;
216 self.ensure_durable_store_facets()?;
217 if let ProcessInput::External { metadata } = registration.input.as_ref() {
218 return Ok(ProcessAwaitOutput::Success {
219 value: serde_json::json!({ "metadata": metadata.clone() }),
220 control: None,
221 });
222 }
223 let mut runtime = self.runtime_for_registration(®istration).await?;
224 let originator_scope = if let crate::ProcessOriginator::Session { scope } =
225 ®istration.provenance.originator
226 {
227 Some(scope)
228 } else {
229 None
230 };
231 let probe_scope = registration.wake_target.as_ref().or(originator_scope);
232 if let Some(probe) =
233 probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
234 {
235 runtime.set_turn_phase_probe(probe);
236 }
237 let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
238 PluginError::Session(format!(
239 "failed to build runtime env for process `{}`: {err}",
240 registration.id
241 ))
242 })?;
243 Ok(manager
244 .run_process(
245 registration,
246 execution_context,
247 Arc::clone(&self.config.process_registry),
248 scoped_effect_controller,
249 cancellation,
250 )
251 .await)
252 }
253
254 pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
280 let records = self.config.process_registry.list_non_terminal().await?;
281 for record in records {
282 let worker = self.clone();
293 tokio::spawn(async move { worker.recover_process(record).await });
294 }
295 Ok(())
296 }
297
298 fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
306 let attempt = uuid::Uuid::new_v4();
307 crate::LeaseOwnerIdentity {
308 owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
309 incarnation_id: attempt.to_string(),
310 liveness: self.config.lease_owner.liveness.clone(),
311 }
312 }
313
314 async fn recover_process(&self, record: ProcessRecord) {
315 let process_id = record.id.clone();
316 let lease_ttl_ms = self.lease_timings().ttl_ms();
317 let owner = &self.recovery_lease_owner();
318 let lease = match self
326 .config
327 .process_registry
328 .claim_process_lease(&process_id, owner, lease_ttl_ms)
329 .await
330 {
331 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
332 Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
333 if holder.owner.is_definitely_dead_for_claimant(owner) =>
334 {
335 match self
336 .config
337 .process_registry
338 .reclaim_process_lease(&process_id, owner, &holder, lease_ttl_ms)
339 .await
340 {
341 Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
342 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
343 }
344 }
345 Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
346 };
347 if self
350 .config
351 .process_registry
352 .get_process(&process_id)
353 .await
354 .is_some_and(|current| current.is_terminal())
355 {
356 self.release_or_log(&lease).await;
357 return;
358 }
359 let registration = ProcessRegistration {
360 id: record.id,
361 input: record.input,
362 identity: record.identity,
363 event_types: record.event_types,
364 provenance: record.provenance.clone(),
365 env_ref: record.env_ref.clone(),
366 wake_target: record.wake_target.clone(),
367 };
368 let execution_context = ProcessExecutionContext::default();
369 match self
370 .run_process_with_lease_renewal(registration, execution_context, lease.clone())
371 .await
372 {
373 Ok(output) => self.complete_and_release(&lease, &process_id, output).await,
376 Err(RecoverFailure::LeaseLost(err)) => {
382 tracing::warn!(
383 process_id = %process_id,
384 error = %err,
385 "process recovery lost its lease mid-run; deferring to the new owner",
386 );
387 }
388 Err(RecoverFailure::Run(err)) => {
391 let output = ProcessAwaitOutput::Failure {
392 class: crate::ToolFailureClass::Execution,
393 code: "process_recovery_failed".to_string(),
394 message: err.to_string(),
395 raw: None,
396 control: None,
397 };
398 self.complete_and_release(&lease, &process_id, output).await;
399 }
400 }
401 }
402
403 async fn complete_and_release(
407 &self,
408 lease: &ProcessLease,
409 process_id: &str,
410 output: ProcessAwaitOutput,
411 ) {
412 let fenced = match self
419 .config
420 .process_registry
421 .renew_process_lease(lease, self.lease_timings().ttl_ms())
422 .await
423 {
424 Ok(renewed) => renewed,
425 Err(err) => {
426 tracing::warn!(
427 process_id = %process_id,
428 error = %err,
429 "lost process lease before terminal write; deferring to the new owner",
430 );
431 return;
432 }
433 };
434 if let Err(err) = self
435 .config
436 .process_registry
437 .complete_process(process_id, output)
438 .await
439 {
440 tracing::warn!(
441 process_id = %process_id,
442 error = %err,
443 "failed to write recovered process terminal outcome",
444 );
445 }
446 self.release_or_log(&fenced).await;
447 }
448
449 async fn release_or_log(&self, lease: &ProcessLease) {
450 if let Err(err) = self.release_process_lease(lease).await {
451 tracing::warn!(
452 process_id = %lease.process_id,
453 error = %err,
454 "failed to release recovered process lease",
455 );
456 }
457 }
458
459 async fn run_process_with_lease_renewal(
463 &self,
464 registration: ProcessRegistration,
465 execution_context: ProcessExecutionContext,
466 mut lease: ProcessLease,
467 ) -> Result<ProcessAwaitOutput, RecoverFailure> {
468 let process_id = registration.id.clone();
469 let cancellation = CancellationToken::new();
470 let cancel_watcher = {
471 let registry = Arc::clone(&self.config.process_registry);
472 let process_id = process_id.clone();
473 let cancellation = cancellation.clone();
474 tokio::spawn(async move {
475 match registry
476 .wait_event_after(&process_id, "process.cancel_requested", 0)
477 .await
478 {
479 Ok(_) => cancellation.cancel(),
480 Err(err) => tracing::warn!(
481 process_id = %process_id,
482 error = %err,
483 "process cancel watcher stopped before observing cancellation",
484 ),
485 }
486 })
487 };
488 let pending = self.run_process(registration, execution_context, cancellation.clone());
489 tokio::pin!(pending);
490 loop {
491 tokio::select! {
492 outcome = &mut pending => {
493 cancel_watcher.abort();
494 return outcome.map_err(RecoverFailure::Run);
495 }
496 _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
497 match self
498 .config
499 .process_registry
500 .renew_process_lease(&lease, self.lease_timings().ttl_ms())
501 .await
502 {
503 Ok(renewed) => lease = renewed,
504 Err(err) => {
505 cancellation.cancel();
506 cancel_watcher.abort();
507 return Err(RecoverFailure::LeaseLost(err));
508 }
509 }
510 }
511 }
512 }
513 }
514
515 fn lease_timings(&self) -> crate::LeaseTimings {
516 self.config.runtime_host.control.lease_timings
517 }
518
519 async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
520 self.config
521 .process_registry
522 .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
523 .await
524 }
525
526 pub async fn request_process_cancel(
527 &self,
528 process_id: &str,
529 reason: Option<String>,
530 ) -> Result<(), PluginError> {
531 self.config
532 .process_registry
533 .append_event(
534 process_id,
535 crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
536 )
537 .await
538 .map(|_| ())
539 }
540
541 async fn runtime_for_registration(
542 &self,
543 registration: &ProcessRegistration,
544 ) -> Result<LashRuntime, PluginError> {
545 match registration.input.as_ref() {
546 ProcessInput::SessionTurn { create_request, .. } => {
547 self.runtime_for_session_turn(registration, create_request.as_ref())
548 .await
549 }
550 ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
551 self.runtime_for_process_env(registration).await
552 }
553 ProcessInput::External { .. } => unreachable!("external processes short-circuit"),
554 }
555 }
556
557 async fn runtime_for_session_turn(
558 &self,
559 registration: &ProcessRegistration,
560 create_request: &crate::SessionCreateRequest,
561 ) -> Result<LashRuntime, PluginError> {
562 let mut policy = create_request
563 .policy
564 .clone()
565 .unwrap_or_else(|| self.config.session_policy.clone());
566 if policy.recorded_provider_id().is_empty() {
567 policy.provider_id = self.config.session_policy.provider_id.clone();
568 }
569 self.build_ephemeral_runtime(
570 format!("process-session-turn:{}", registration.id),
571 policy,
572 create_request.plugin_options.clone(),
573 "session turn request",
574 )
575 .await
576 }
577
578 async fn runtime_for_process_env(
579 &self,
580 registration: &ProcessRegistration,
581 ) -> Result<LashRuntime, PluginError> {
582 let Some(env_ref) = registration.env_ref.as_ref() else {
583 return Err(PluginError::Session(format!(
584 "process `{}` is missing a captured execution env",
585 registration.id
586 )));
587 };
588 let env = crate::load_process_execution_env(
589 self.config
590 .runtime_host
591 .durability
592 .process_env_store
593 .as_ref(),
594 env_ref,
595 )
596 .await?;
597 self.build_ephemeral_runtime(
598 format!("process-env:{}", registration.id),
599 env.policy,
600 env.plugin_options,
601 env_ref.as_str(),
602 )
603 .await
604 }
605
606 async fn build_ephemeral_runtime(
607 &self,
608 session_id: String,
609 policy: crate::SessionPolicy,
610 plugin_options: crate::PluginOptions,
611 source_label: &str,
612 ) -> Result<LashRuntime, PluginError> {
613 let store = Arc::new(InMemorySessionStore::default());
614 let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
615 ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
616 });
617 let mut builder = EmbeddedRuntimeBuilder::new()
618 .with_session_id(session_id.to_string())
619 .with_plugin_host(self.config.plugin_host.as_ref().clone())
620 .with_runtime_host(self.config.runtime_host.clone())
621 .with_policy(policy)
622 .with_plugin_options(plugin_options)
623 .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
624 .with_trigger_store(Arc::clone(&self.config.trigger_store))
625 .with_process_registry(Arc::clone(&self.config.process_registry))
626 .with_process_work_driver(process_work_driver)
627 .with_residency(self.config.residency)
628 .with_store(store);
629 if let Some(driver) = self.config.queued_work_driver.clone() {
630 builder = builder.with_queued_work_driver(driver);
631 }
632 builder.build().await.map_err(|err| {
633 PluginError::Session(format!(
634 "failed to build process worker runtime for {source_label}: {err}"
635 ))
636 })
637 }
638
639 fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
648 if self
649 .config
650 .runtime_host
651 .control
652 .effect_host
653 .durability_tier()
654 != crate::DurabilityTier::Durable
655 {
656 return Ok(());
657 }
658 let require = |facet: crate::DurableStoreFacet| {
659 PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
660 };
661 if self
662 .config
663 .runtime_host
664 .durability
665 .attachment_store
666 .persistence()
667 .durability_tier()
668 != crate::DurabilityTier::Durable
669 {
670 return Err(require(crate::DurableStoreFacet::AttachmentStore));
671 }
672 if self
673 .config
674 .runtime_host
675 .durability
676 .process_env_store
677 .durability_tier()
678 != crate::DurabilityTier::Durable
679 {
680 return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
681 }
682 if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
683 return Err(require(crate::DurableStoreFacet::SessionStore));
684 }
685 if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
686 return Err(require(crate::DurableStoreFacet::ProcessRegistry));
687 }
688 if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
689 return Err(require(crate::DurableStoreFacet::TriggerStore));
690 }
691 Ok(())
692 }
693
694 fn ensure_stable_process_id(
702 &self,
703 registration: &ProcessRegistration,
704 ) -> Result<(), PluginError> {
705 if registration.id.trim().is_empty() {
706 return Err(PluginError::Session(
707 crate::RuntimeError::missing_process_execution_id().to_string(),
708 ));
709 }
710 Ok(())
711 }
712}
713
714#[cfg(test)]
715mod recovery_tests {
716 use super::*;
717 use crate::{
718 DurabilityTier, LeaseOwnerIdentity, LeaseOwnerLiveness, ProcessInput, ProcessRegistration,
719 };
720
721 fn inline_worker(
722 registry: Arc<dyn ProcessRegistry>,
723 lease_owner: LeaseOwnerIdentity,
724 ) -> DurableProcessWorker {
725 struct InlineSessionStoreFactory;
726
727 #[async_trait::async_trait]
728 impl SessionStoreFactory for InlineSessionStoreFactory {
729 fn durability_tier(&self) -> DurabilityTier {
730 DurabilityTier::Inline
731 }
732
733 async fn create_store(
734 &self,
735 _request: &crate::SessionStoreCreateRequest,
736 ) -> Result<Arc<dyn crate::RuntimePersistence>, String> {
737 Ok(Arc::new(InMemorySessionStore::default()))
738 }
739
740 async fn delete_session(&self, _session_id: &str) -> Result<(), String> {
741 Ok(())
742 }
743 }
744
745 DurableProcessWorker::new(
746 DurableProcessWorkerConfig::new(
747 Arc::new(PluginHost::new(Vec::new())),
748 RuntimeHostConfig::in_memory(),
749 Arc::new(InlineSessionStoreFactory),
750 registry,
751 )
752 .with_lease_owner(lease_owner),
753 )
754 }
755
756 fn external_registration(id: &str) -> ProcessRegistration {
757 ProcessRegistration::new(
758 id,
759 ProcessInput::External {
760 metadata: serde_json::json!({}),
761 },
762 crate::ProcessProvenance::host(),
763 )
764 }
765
766 fn local_owner(owner_id: &str, host_id: &str, process_start: &str) -> LeaseOwnerIdentity {
767 LeaseOwnerIdentity {
768 owner_id: owner_id.to_string(),
769 incarnation_id: format!("{owner_id}:incarnation"),
770 liveness: LeaseOwnerLiveness::local_process_for_test(
771 host_id,
772 "boot-a",
773 std::process::id(),
774 process_start,
775 ),
776 }
777 }
778
779 async fn await_terminal(registry: &Arc<dyn ProcessRegistry>, process_id: &str) {
780 tokio::time::timeout(
781 std::time::Duration::from_secs(5),
782 registry.await_process(process_id),
783 )
784 .await
785 .expect("recovered process reaches terminal within the sweep")
786 .expect("recovered process terminal output");
787 }
788
789 #[tokio::test]
792 async fn sweep_reclaims_dead_holder_lease_and_recovers_the_process() {
793 let registry: Arc<dyn ProcessRegistry> =
794 Arc::new(crate::TestLocalProcessRegistry::default());
795 registry
796 .register_process(external_registration("proc-sweep-reclaim"))
797 .await
798 .expect("register");
799 let dead_holder = local_owner("dead-worker", "host-a", "not-the-current-process-start");
800 registry
801 .claim_process_lease("proc-sweep-reclaim", &dead_holder, 60_000)
802 .await
803 .expect("dead holder claims")
804 .acquired()
805 .expect("dead holder lease acquired");
806
807 let claimant = local_owner("live-worker", "host-a", "claimant-start");
808 let worker = inline_worker(Arc::clone(®istry), claimant);
809 worker
810 .drive_pending_processes()
811 .await
812 .expect("sweep dispatches");
813 await_terminal(®istry, "proc-sweep-reclaim").await;
814 }
815
816 #[tokio::test]
818 async fn sweep_skips_rows_whose_holder_is_not_provably_dead() {
819 let registry: Arc<dyn ProcessRegistry> =
820 Arc::new(crate::TestLocalProcessRegistry::default());
821 registry
822 .register_process(external_registration("proc-sweep-skip"))
823 .await
824 .expect("register");
825 registry
827 .claim_process_lease(
828 "proc-sweep-skip",
829 &LeaseOwnerIdentity::opaque("other-worker", "other-incarnation"),
830 60_000,
831 )
832 .await
833 .expect("live holder claims")
834 .acquired()
835 .expect("live holder lease acquired");
836
837 let claimant = local_owner("live-worker", "host-a", "claimant-start");
838 let worker = inline_worker(Arc::clone(®istry), claimant);
839 worker
840 .drive_pending_processes()
841 .await
842 .expect("sweep dispatches");
843 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
844 let record = registry
845 .get_process("proc-sweep-skip")
846 .await
847 .expect("process exists");
848 assert!(
849 !record.is_terminal(),
850 "a live-leased process must not be re-run by the sweep"
851 );
852 }
853}
854
855#[cfg(test)]
856mod boundary_tests {
857 use super::*;
858 use crate::{
859 AttachmentStore, AttachmentStoreError, AttachmentStorePersistence, DurabilityTier,
860 DurableStoreFacet, InMemoryAttachmentStore, ProcessExecutionEnvRef,
861 ProcessExecutionEnvStore, ProcessInput, ProcessRegistration, RuntimeEffectController,
862 RuntimeError, StoredAttachment, TriggerStore,
863 };
864 use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentRef};
865
866 #[derive(Default)]
869 struct DurableController;
870
871 impl crate::AwaitEventResolver for DurableController {
872 fn durability_tier(&self) -> DurabilityTier {
873 DurabilityTier::Durable
874 }
875 }
876
877 #[async_trait::async_trait]
878 impl RuntimeEffectController for DurableController {
879 async fn execute_effect(
880 &self,
881 _envelope: crate::RuntimeEffectEnvelope,
882 _local_executor: crate::RuntimeEffectLocalExecutor<'_>,
883 ) -> Result<crate::RuntimeEffectOutcome, crate::RuntimeEffectControllerError> {
884 unreachable!("worker boundary rejects before executing any effect")
885 }
886 }
887
888 #[derive(Default)]
890 struct DurableAttachmentStore {
891 inner: InMemoryAttachmentStore,
892 }
893
894 #[async_trait::async_trait]
895 impl AttachmentStore for DurableAttachmentStore {
896 fn persistence(&self) -> AttachmentStorePersistence {
897 AttachmentStorePersistence::Durable
898 }
899
900 async fn put(
901 &self,
902 bytes: Vec<u8>,
903 meta: AttachmentCreateMeta,
904 ) -> Result<AttachmentRef, AttachmentStoreError> {
905 self.inner.put(bytes, meta).await
906 }
907
908 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
909 self.inner.get(id).await
910 }
911
912 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
913 self.inner.delete(id).await
914 }
915 }
916
917 #[derive(Default)]
919 struct DurableProcessEnvStore {
920 inner: crate::InMemoryProcessExecutionEnvStore,
921 }
922
923 #[async_trait::async_trait]
924 impl ProcessExecutionEnvStore for DurableProcessEnvStore {
925 fn durability_tier(&self) -> DurabilityTier {
926 DurabilityTier::Durable
927 }
928
929 async fn put_process_execution_env(
930 &self,
931 env_ref: &ProcessExecutionEnvRef,
932 bytes: &[u8],
933 ) -> Result<(), PluginError> {
934 self.inner.put_process_execution_env(env_ref, bytes).await
935 }
936
937 async fn get_process_execution_env(
938 &self,
939 env_ref: &ProcessExecutionEnvRef,
940 ) -> Result<Option<Vec<u8>>, PluginError> {
941 self.inner.get_process_execution_env(env_ref).await
942 }
943 }
944
945 struct TierSessionStoreFactory {
948 tier: DurabilityTier,
949 }
950
951 #[async_trait::async_trait]
952 impl SessionStoreFactory for TierSessionStoreFactory {
953 fn durability_tier(&self) -> DurabilityTier {
954 self.tier
955 }
956
957 async fn create_store(
958 &self,
959 _request: &crate::SessionStoreCreateRequest,
960 ) -> Result<Arc<dyn crate::RuntimePersistence>, String> {
961 unreachable!("worker boundary rejects before creating a session store")
962 }
963
964 async fn delete_session(&self, _session_id: &str) -> Result<(), String> {
965 Ok(())
966 }
967 }
968
969 struct TierTriggerStore {
970 tier: DurabilityTier,
971 inner: crate::InMemoryTriggerStore,
972 }
973
974 impl TierTriggerStore {
975 fn new(tier: DurabilityTier) -> Self {
976 Self {
977 tier,
978 inner: crate::InMemoryTriggerStore::default(),
979 }
980 }
981 }
982
983 #[async_trait::async_trait]
984 impl TriggerStore for TierTriggerStore {
985 fn durability_tier(&self) -> DurabilityTier {
986 self.tier
987 }
988
989 async fn register_subscription(
990 &self,
991 draft: crate::TriggerSubscriptionDraft,
992 ) -> Result<crate::TriggerSubscriptionRecord, PluginError> {
993 self.inner.register_subscription(draft).await
994 }
995
996 async fn list_subscriptions(
997 &self,
998 filter: crate::TriggerSubscriptionFilter,
999 ) -> Result<Vec<crate::TriggerSubscriptionRecord>, PluginError> {
1000 self.inner.list_subscriptions(filter).await
1001 }
1002
1003 async fn cancel_subscription(
1004 &self,
1005 session_id: &str,
1006 handle: &str,
1007 ) -> Result<bool, PluginError> {
1008 self.inner.cancel_subscription(session_id, handle).await
1009 }
1010
1011 async fn delete_session_subscriptions(
1012 &self,
1013 session_id: &str,
1014 ) -> Result<usize, PluginError> {
1015 self.inner.delete_session_subscriptions(session_id).await
1016 }
1017
1018 async fn record_occurrence(
1019 &self,
1020 request: crate::TriggerOccurrenceRequest,
1021 ) -> Result<crate::TriggerOccurrenceRecord, PluginError> {
1022 self.inner.record_occurrence(request).await
1023 }
1024
1025 async fn reserve_matching_deliveries(
1026 &self,
1027 occurrence_id: &str,
1028 ) -> Result<Vec<crate::TriggerDeliveryReservation>, PluginError> {
1029 self.inner.reserve_matching_deliveries(occurrence_id).await
1030 }
1031 }
1032
1033 fn worker(
1037 attachment: Arc<dyn AttachmentStore>,
1038 process_env_store: Arc<dyn ProcessExecutionEnvStore>,
1039 session_store_tier: DurabilityTier,
1040 ) -> DurableProcessWorker {
1041 worker_with_store_tiers(
1042 attachment,
1043 process_env_store,
1044 session_store_tier,
1045 DurabilityTier::Durable,
1046 DurabilityTier::Durable,
1047 )
1048 }
1049
1050 fn worker_with_store_tiers(
1051 attachment: Arc<dyn AttachmentStore>,
1052 process_env_store: Arc<dyn ProcessExecutionEnvStore>,
1053 session_store_tier: DurabilityTier,
1054 process_registry_tier: DurabilityTier,
1055 trigger_store_tier: DurabilityTier,
1056 ) -> DurableProcessWorker {
1057 let mut runtime_host = RuntimeHostConfig::in_memory();
1058 runtime_host.control.effect_host =
1059 Arc::new(crate::InlineEffectHost::new(Arc::new(DurableController)));
1060 runtime_host.durability.attachment_store = attachment;
1061 runtime_host.durability.process_env_store = process_env_store;
1062 let plugin_host = Arc::new(crate::PluginHost::new(Vec::new()));
1063 let factory: Arc<dyn SessionStoreFactory> = Arc::new(TierSessionStoreFactory {
1064 tier: session_store_tier,
1065 });
1066 let registry: Arc<dyn ProcessRegistry> = Arc::new(
1067 crate::TestLocalProcessRegistry::default().with_durability_tier(process_registry_tier),
1068 );
1069 let trigger_store: Arc<dyn TriggerStore> =
1070 Arc::new(TierTriggerStore::new(trigger_store_tier));
1071 DurableProcessWorker::new(
1072 DurableProcessWorkerConfig::new(plugin_host, runtime_host, factory, registry)
1073 .with_trigger_store(trigger_store),
1074 )
1075 }
1076
1077 fn external_registration() -> ProcessRegistration {
1078 ProcessRegistration::new(
1079 "worker-boundary-process",
1080 ProcessInput::External {
1081 metadata: serde_json::json!({}),
1082 },
1083 crate::ProcessProvenance::host(),
1084 )
1085 }
1086
1087 async fn run(worker: &DurableProcessWorker) -> Result<ProcessAwaitOutput, PluginError> {
1088 worker
1089 .run_process(
1090 external_registration(),
1091 ProcessExecutionContext::default(),
1092 CancellationToken::new(),
1093 )
1094 .await
1095 }
1096
1097 fn assert_facet(err: PluginError, facet: DurableStoreFacet) {
1098 let PluginError::Session(message) = err else {
1099 panic!("expected PluginError::Session, got {err:?}");
1100 };
1101 let expected = RuntimeError::durable_store_required(facet).to_string();
1102 assert_eq!(message, expected, "worker must reject the {facet:?} facet");
1103 }
1104
1105 #[tokio::test]
1106 async fn durable_worker_rejects_ephemeral_attachment_store() {
1107 let worker = worker(
1108 Arc::new(InMemoryAttachmentStore::new()),
1109 Arc::new(DurableProcessEnvStore::default()),
1110 DurabilityTier::Durable,
1111 );
1112 let err = run(&worker)
1113 .await
1114 .expect_err("ephemeral attachment store must be rejected at the worker boundary");
1115 assert_facet(err, DurableStoreFacet::AttachmentStore);
1116 }
1117
1118 #[tokio::test]
1119 async fn durable_worker_rejects_ephemeral_process_env_store() {
1120 let worker = worker(
1121 Arc::new(DurableAttachmentStore::default()),
1122 Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
1123 DurabilityTier::Durable,
1124 );
1125 let err = run(&worker)
1126 .await
1127 .expect_err("ephemeral process env store must be rejected at the worker boundary");
1128 assert_facet(err, DurableStoreFacet::ProcessEnvStore);
1129 }
1130
1131 #[tokio::test]
1132 async fn durable_worker_rejects_ephemeral_session_store_factory() {
1133 let worker = worker(
1134 Arc::new(DurableAttachmentStore::default()),
1135 Arc::new(DurableProcessEnvStore::default()),
1136 DurabilityTier::Inline,
1137 );
1138 let err = run(&worker)
1139 .await
1140 .expect_err("ephemeral session store factory must be rejected at the worker boundary");
1141 assert_facet(err, DurableStoreFacet::SessionStore);
1142 }
1143
1144 #[tokio::test]
1145 async fn durable_worker_rejects_ephemeral_process_registry() {
1146 let worker = worker_with_store_tiers(
1147 Arc::new(DurableAttachmentStore::default()),
1148 Arc::new(DurableProcessEnvStore::default()),
1149 DurabilityTier::Durable,
1150 DurabilityTier::Inline,
1151 DurabilityTier::Durable,
1152 );
1153 let err = run(&worker)
1154 .await
1155 .expect_err("ephemeral process registry must be rejected at the worker boundary");
1156 assert_facet(err, DurableStoreFacet::ProcessRegistry);
1157 }
1158
1159 #[tokio::test]
1160 async fn durable_worker_rejects_ephemeral_trigger_store() {
1161 let worker = worker_with_store_tiers(
1162 Arc::new(DurableAttachmentStore::default()),
1163 Arc::new(DurableProcessEnvStore::default()),
1164 DurabilityTier::Durable,
1165 DurabilityTier::Durable,
1166 DurabilityTier::Inline,
1167 );
1168 let err = run(&worker)
1169 .await
1170 .expect_err("ephemeral trigger store must be rejected at the worker boundary");
1171 assert_facet(err, DurableStoreFacet::TriggerStore);
1172 }
1173
1174 #[tokio::test]
1175 async fn durable_worker_with_all_durable_stores_passes_store_facet_check() {
1176 let worker = worker(
1180 Arc::new(DurableAttachmentStore::default()),
1181 Arc::new(DurableProcessEnvStore::default()),
1182 DurabilityTier::Durable,
1183 );
1184 let output = run(&worker)
1185 .await
1186 .expect("all-durable worker should pass the store-facet guard");
1187 assert!(matches!(output, ProcessAwaitOutput::Success { .. }));
1188 }
1189
1190 #[tokio::test]
1191 async fn inline_worker_passes_store_facet_check_with_ephemeral_stores() {
1192 let runtime_host = RuntimeHostConfig::in_memory();
1195 let plugin_host = Arc::new(crate::PluginHost::new(Vec::new()));
1196 let factory: Arc<dyn SessionStoreFactory> = Arc::new(TierSessionStoreFactory {
1197 tier: DurabilityTier::Inline,
1198 });
1199 let registry: Arc<dyn ProcessRegistry> =
1200 Arc::new(crate::TestLocalProcessRegistry::default());
1201 let worker = DurableProcessWorker::new(DurableProcessWorkerConfig::new(
1202 plugin_host,
1203 runtime_host,
1204 factory,
1205 registry,
1206 ));
1207 let output = run(&worker)
1208 .await
1209 .expect("inline worker should pass the store-facet guard");
1210 assert!(matches!(output, ProcessAwaitOutput::Success { .. }));
1211 }
1212}