duroxide/client/mod.rs
1use std::sync::Arc;
2
3use tracing::info;
4
5use crate::_typed_codec::{Codec, Json};
6use crate::providers::{
7 DeleteInstanceResult, ExecutionInfo, InstanceFilter, InstanceInfo, InstanceTree, Provider, ProviderAdmin,
8 ProviderError, PruneOptions, PruneResult, QueueDepths, SystemMetrics, WorkItem,
9};
10use crate::{EventKind, OrchestrationStatus};
11use serde::Serialize;
12
13/// Client-specific error type that wraps provider errors and adds client-specific errors.
14///
15/// This enum allows callers to distinguish between:
16/// - Provider errors (storage failures, can be retryable or permanent)
17/// - Client-specific errors (validation, capability not available, etc.)
18#[derive(Debug, Clone)]
19pub enum ClientError {
20 /// Provider operation failed (wraps ProviderError)
21 Provider(ProviderError),
22
23 /// Management capability not available
24 ManagementNotAvailable,
25
26 /// Invalid input (client validation)
27 InvalidInput { message: String },
28
29 /// Operation timed out
30 Timeout,
31
32 /// Instance is still running (for delete without force)
33 InstanceStillRunning { instance_id: String },
34
35 /// Cannot delete a sub-orchestration directly (must delete root)
36 CannotDeleteSubOrchestration { instance_id: String },
37
38 /// Instance not found
39 InstanceNotFound { instance_id: String },
40}
41
42impl ClientError {
43 /// Check if this error is retryable (only applies to Provider errors)
44 pub fn is_retryable(&self) -> bool {
45 match self {
46 ClientError::Provider(e) => e.is_retryable(),
47 ClientError::ManagementNotAvailable => false,
48 ClientError::InvalidInput { .. } => false,
49 ClientError::Timeout => true,
50 ClientError::InstanceStillRunning { .. } => false,
51 ClientError::CannotDeleteSubOrchestration { .. } => false,
52 ClientError::InstanceNotFound { .. } => false,
53 }
54 }
55}
56
57impl std::fmt::Display for ClientError {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 ClientError::Provider(e) => write!(f, "{e}"),
61 ClientError::ManagementNotAvailable => write!(
62 f,
63 "Management features not available - provider doesn't implement ProviderAdmin"
64 ),
65 ClientError::InvalidInput { message } => write!(f, "Invalid input: {message}"),
66 ClientError::Timeout => write!(f, "Operation timed out"),
67 ClientError::InstanceStillRunning { instance_id } => write!(
68 f,
69 "Instance {instance_id} is still running. Use force=true or cancel first."
70 ),
71 ClientError::CannotDeleteSubOrchestration { instance_id } => write!(
72 f,
73 "Cannot delete sub-orchestration {instance_id} directly. Delete the root orchestration instead."
74 ),
75 ClientError::InstanceNotFound { instance_id } => {
76 write!(f, "Instance {instance_id} not found")
77 }
78 }
79 }
80}
81
82impl std::error::Error for ClientError {}
83
84impl From<ProviderError> for ClientError {
85 fn from(e: ProviderError) -> Self {
86 ClientError::Provider(e)
87 }
88}
89
90// Constants for polling behavior in wait_for_orchestration
91/// Initial delay between status polls (5ms)
92const INITIAL_POLL_DELAY_MS: u64 = 5;
93
94/// Maximum delay between status polls (100ms)
95const MAX_POLL_DELAY_MS: u64 = 100;
96
97/// Multiplier for exponential backoff
98const POLL_DELAY_MULTIPLIER: u64 = 2;
99
100/// Client for orchestration control-plane operations with automatic capability discovery.
101///
102/// The Client provides APIs for managing orchestration instances:
103/// - Starting orchestrations
104/// - Raising external events
105/// - Cancelling instances
106/// - Checking status
107/// - Waiting for completion
108/// - Rich management features (when available)
109///
110/// # Automatic Capability Discovery
111///
112/// The Client automatically discovers provider capabilities through the `Provider::as_management_capability()` method.
113/// When a provider implements `ProviderAdmin`, rich management features become available:
114///
115/// ```ignore
116/// let client = Client::new(provider);
117///
118/// // Control plane (always available)
119/// client.start_orchestration("order-1", "ProcessOrder", "{}").await?;
120///
121/// // Management (automatically discovered)
122/// if client.has_management_capability() {
123/// let instances = client.list_all_instances().await?;
124/// let metrics = client.get_system_metrics().await?;
125/// } else {
126/// println!("Management features not available");
127/// }
128/// ```
129///
130/// # Design
131///
132/// The Client communicates with the Runtime **only through the shared Provider** (no direct coupling).
133/// This allows the Client to be used from any process, even one without a running Runtime.
134///
135/// # Thread Safety
136///
137/// Client is `Clone` and can be safely shared across threads.
138///
139/// # Example Usage
140///
141/// ```ignore
142/// use duroxide::{Client, OrchestrationStatus};
143/// use duroxide::providers::sqlite::SqliteProvider;
144/// use std::sync::Arc;
145///
146/// use duroxide::ClientError;
147/// let store = Arc::new(SqliteProvider::new("sqlite:./data.db").await?);
148/// let client = Client::new(store);
149///
150/// // Start an orchestration
151/// client.start_orchestration("order-123", "ProcessOrder", r#"{"customer_id": "c1"}"#).await?;
152///
153/// // Check status
154/// let status = client.get_orchestration_status("order-123").await?;
155/// println!("Status: {:?}", status);
156///
157/// // Wait for completion
158/// let result = client.wait_for_orchestration("order-123", std::time::Duration::from_secs(30)).await.unwrap();
159/// match result {
160/// OrchestrationStatus::Completed { output, .. } => println!("Done: {}", output),
161/// OrchestrationStatus::Failed { details, .. } => {
162/// eprintln!("Failed ({}): {}", details.category(), details.display_message());
163/// }
164/// _ => {}
165/// }
166/// ```
167pub struct Client {
168 store: Arc<dyn Provider>,
169}
170
171impl Client {
172 /// Create a client bound to a Provider instance.
173 ///
174 /// # Parameters
175 ///
176 /// * `store` - Arc-wrapped Provider (same instance used by Runtime)
177 ///
178 /// # Example
179 ///
180 /// ```ignore
181 /// let store = Arc::new(SqliteProvider::new("sqlite::memory:").await.unwrap());
182 /// let client = Client::new(store.clone());
183 /// // Multiple clients can share the same store
184 /// let client2 = client.clone();
185 /// ```
186 pub fn new(store: Arc<dyn Provider>) -> Self {
187 Self { store }
188 }
189
190 /// Start an orchestration instance with string input.
191 ///
192 /// # Parameters
193 ///
194 /// * `instance` - Unique instance ID (e.g., "order-123", "user-payment-456")
195 /// * `orchestration` - Name of registered orchestration (e.g., "ProcessOrder")
196 /// * `input` - JSON string input (will be passed to orchestration)
197 ///
198 /// # Returns
199 ///
200 /// * `Ok(())` - Instance was enqueued for processing
201 /// * `Err(msg)` - Failed to enqueue (storage error)
202 ///
203 /// # Behavior
204 ///
205 /// - Enqueues a StartOrchestration work item
206 /// - Returns immediately (doesn't wait for orchestration to start/complete)
207 /// - Use `wait_for_orchestration()` to wait for completion
208 ///
209 /// # Instance ID Requirements
210 ///
211 /// - Must be unique across all orchestrations
212 /// - Can be any string (alphanumeric + hyphens recommended)
213 /// - Reusing an instance ID that already exists will fail
214 ///
215 /// # Example
216 ///
217 /// ```rust,no_run
218 /// # use duroxide::{Client, ClientError};
219 /// # use std::sync::Arc;
220 /// # async fn example(client: Client) -> Result<(), ClientError> {
221 /// // Start with JSON string input
222 /// client.start_orchestration(
223 /// "order-123",
224 /// "ProcessOrder",
225 /// r#"{"customer_id": "c1", "items": ["item1", "item2"]}"#
226 /// ).await?;
227 /// # Ok(())
228 /// # }
229 /// ```
230 ///
231 /// # Errors
232 ///
233 /// Returns `ClientError::Provider` if the provider fails to enqueue the orchestration.
234 pub async fn start_orchestration(
235 &self,
236 instance: impl Into<String>,
237 orchestration: impl Into<String>,
238 input: impl Into<String>,
239 ) -> Result<(), ClientError> {
240 let item = WorkItem::StartOrchestration {
241 instance: instance.into(),
242 orchestration: orchestration.into(),
243 input: input.into(),
244 version: None,
245 parent_instance: None,
246 parent_id: None,
247 execution_id: crate::INITIAL_EXECUTION_ID,
248 };
249 self.store
250 .enqueue_for_orchestrator(item, None)
251 .await
252 .map_err(ClientError::from)
253 }
254
255 /// Start an orchestration instance pinned to a specific version.
256 ///
257 /// # Errors
258 ///
259 /// Returns `ClientError::Provider` if the provider fails to enqueue the orchestration.
260 pub async fn start_orchestration_versioned(
261 &self,
262 instance: impl Into<String>,
263 orchestration: impl Into<String>,
264 version: impl Into<String>,
265 input: impl Into<String>,
266 ) -> Result<(), ClientError> {
267 let item = WorkItem::StartOrchestration {
268 instance: instance.into(),
269 orchestration: orchestration.into(),
270 input: input.into(),
271 version: Some(version.into()),
272 parent_instance: None,
273 parent_id: None,
274 execution_id: crate::INITIAL_EXECUTION_ID,
275 };
276 self.store
277 .enqueue_for_orchestrator(item, None)
278 .await
279 .map_err(ClientError::from)
280 }
281
282 // Note: No delayed scheduling API. Clients should use normal start APIs.
283
284 /// Start an orchestration with typed input (serialized to JSON).
285 ///
286 /// # Errors
287 ///
288 /// Returns `ClientError::InvalidInput` if serialization fails.
289 /// Returns `ClientError::Provider` if the provider fails to enqueue the orchestration.
290 pub async fn start_orchestration_typed<In: Serialize>(
291 &self,
292 instance: impl Into<String>,
293 orchestration: impl Into<String>,
294 input: In,
295 ) -> Result<(), ClientError> {
296 let payload = Json::encode(&input).map_err(|e| ClientError::InvalidInput {
297 message: format!("encode: {e}"),
298 })?;
299 self.start_orchestration(instance, orchestration, payload).await
300 }
301
302 /// Start a versioned orchestration with typed input (serialized to JSON).
303 ///
304 /// # Errors
305 ///
306 /// Returns `ClientError::InvalidInput` if serialization fails.
307 /// Returns `ClientError::Provider` if the provider fails to enqueue the orchestration.
308 pub async fn start_orchestration_versioned_typed<In: Serialize>(
309 &self,
310 instance: impl Into<String>,
311 orchestration: impl Into<String>,
312 version: impl Into<String>,
313 input: In,
314 ) -> Result<(), ClientError> {
315 let payload = Json::encode(&input).map_err(|e| ClientError::InvalidInput {
316 message: format!("encode: {e}"),
317 })?;
318 self.start_orchestration_versioned(instance, orchestration, version, payload)
319 .await
320 }
321
322 /// Raise an external event into a running orchestration instance.
323 ///
324 /// # Purpose
325 ///
326 /// Send a signal/message to a running orchestration that is waiting for an external event.
327 /// The orchestration must have called `ctx.schedule_wait(event_name)` to receive the event.
328 ///
329 /// # Parameters
330 ///
331 /// * `instance` - Instance ID of the running orchestration
332 /// * `event_name` - Name of the event (must match `schedule_wait` name)
333 /// * `data` - Payload data (JSON string, passed to orchestration)
334 ///
335 /// # Behavior
336 ///
337 /// - Enqueues ExternalRaised work item to orchestrator queue
338 /// - If instance isn't waiting for this event (yet), it's buffered
339 /// - Event is matched by NAME (not correlation ID)
340 /// - Multiple events with same name can be raised
341 ///
342 /// # Example
343 ///
344 /// ```rust,no_run
345 /// # use duroxide::{Client, ClientError};
346 /// # async fn example(client: Client) -> Result<(), ClientError> {
347 /// // Orchestration waiting for approval
348 /// // ctx.schedule_wait("ApprovalEvent").await
349 ///
350 /// // External system/human approves
351 /// client.raise_event(
352 /// "order-123",
353 /// "ApprovalEvent",
354 /// r#"{"approved": true, "by": "manager@company.com"}"#
355 /// ).await?;
356 /// # Ok(())
357 /// # }
358 /// ```
359 ///
360 /// # Use Cases
361 ///
362 /// - Human approval workflows
363 /// - Webhook callbacks
364 /// - Inter-orchestration communication
365 /// - External system integration
366 ///
367 /// # Error Cases
368 ///
369 /// - Instance doesn't exist: Event is buffered, orchestration processes when started
370 /// - Instance already completed: Event is ignored gracefully
371 ///
372 /// # Errors
373 ///
374 /// Returns `ClientError::Provider` if the provider fails to enqueue the event.
375 pub async fn raise_event(
376 &self,
377 instance: impl Into<String>,
378 event_name: impl Into<String>,
379 data: impl Into<String>,
380 ) -> Result<(), ClientError> {
381 let item = WorkItem::ExternalRaised {
382 instance: instance.into(),
383 name: event_name.into(),
384 data: data.into(),
385 };
386 self.store
387 .enqueue_for_orchestrator(item, None)
388 .await
389 .map_err(ClientError::from)
390 }
391
392 /// Raise a positional external event with typed data.
393 ///
394 /// Serializes `data` as JSON before sending. Same semantics as [`Self::raise_event`].
395 ///
396 /// # Errors
397 ///
398 /// Returns [`ClientError`] if the provider fails to enqueue the event.
399 pub async fn raise_event_typed<T: serde::Serialize>(
400 &self,
401 instance: impl Into<String>,
402 event_name: impl Into<String>,
403 data: &T,
404 ) -> Result<(), ClientError> {
405 let payload = crate::_typed_codec::Json::encode(data).expect("Serialization should not fail");
406 self.raise_event(instance, event_name, payload).await
407 }
408
409 /// Enqueue a message into a named queue for an orchestration instance.
410 ///
411 /// Queue messages use FIFO mailbox semantics:
412 /// - Matched to [`OrchestrationContext::dequeue_event`] subscriptions in order
413 /// - Stick around until consumed (even if no subscription exists yet)
414 /// - Survive `continue_as_new` boundaries
415 /// - Not affected by subscription cancellation
416 ///
417 /// # Errors
418 ///
419 /// Returns [`ClientError`] if the provider fails to enqueue the message.
420 pub async fn enqueue_event(
421 &self,
422 instance: impl Into<String>,
423 queue: impl Into<String>,
424 data: impl Into<String>,
425 ) -> Result<(), ClientError> {
426 let item = WorkItem::QueueMessage {
427 instance: instance.into(),
428 name: queue.into(),
429 data: data.into(),
430 };
431 self.store
432 .enqueue_for_orchestrator(item, None)
433 .await
434 .map_err(ClientError::from)
435 }
436
437 /// Enqueue a typed message into a named queue for an orchestration instance.
438 ///
439 /// Serializes `data` as JSON before enqueuing. Same semantics as [`Self::enqueue_event`].
440 ///
441 /// # Errors
442 ///
443 /// Returns [`ClientError`] if the provider fails to enqueue the message.
444 pub async fn enqueue_event_typed<T: serde::Serialize>(
445 &self,
446 instance: impl Into<String>,
447 queue: impl Into<String>,
448 data: &T,
449 ) -> Result<(), ClientError> {
450 let payload = crate::_typed_codec::Json::encode(data).expect("Serialization should not fail");
451 self.enqueue_event(instance, queue, payload).await
452 }
453
454 /// Raise a persistent external event that uses mailbox semantics.
455 ///
456 /// Prefer [`Self::enqueue_event`] — this is a deprecated alias.
457 ///
458 /// # Errors
459 ///
460 /// Returns [`ClientError`] if the provider fails to enqueue the event.
461 #[deprecated(note = "Use enqueue_event() instead")]
462 pub async fn raise_event_persistent(
463 &self,
464 instance: impl Into<String>,
465 event_name: impl Into<String>,
466 data: impl Into<String>,
467 ) -> Result<(), ClientError> {
468 self.enqueue_event(instance, event_name, data).await
469 }
470
471 /// V2: Raise an external event with topic-based pub/sub matching.
472 ///
473 /// Same as `raise_event`, but includes a `topic` for pub/sub matching.
474 /// The orchestration must have called `ctx.schedule_wait2(name, topic)` to receive the event.
475 /// Feature-gated for replay engine extensibility verification.
476 ///
477 /// # Errors
478 ///
479 /// Returns [`ClientError`] if the provider fails to enqueue the event.
480 #[cfg(feature = "replay-version-test")]
481 pub async fn raise_event2(
482 &self,
483 instance: impl Into<String>,
484 event_name: impl Into<String>,
485 topic: impl Into<String>,
486 data: impl Into<String>,
487 ) -> Result<(), ClientError> {
488 let item = WorkItem::ExternalRaised2 {
489 instance: instance.into(),
490 name: event_name.into(),
491 topic: topic.into(),
492 data: data.into(),
493 };
494 self.store
495 .enqueue_for_orchestrator(item, None)
496 .await
497 .map_err(ClientError::from)
498 }
499
500 /// Request cancellation of an orchestration instance.
501 ///
502 /// # Purpose
503 ///
504 /// Gracefully cancel a running orchestration. The orchestration will complete its current
505 /// turn and then fail deterministically with a "canceled: {reason}" error.
506 ///
507 /// # Parameters
508 ///
509 /// * `instance` - Instance ID to cancel
510 /// * `reason` - Reason for cancellation (included in error message)
511 ///
512 /// # Behavior
513 ///
514 /// 1. Enqueues CancelInstance work item
515 /// 2. Runtime appends OrchestrationCancelRequested event
516 /// 3. Next turn, orchestration sees cancellation and fails deterministically
517 /// 4. Final status: `OrchestrationStatus::Failed { details: Application::Cancelled }`
518 ///
519 /// # Deterministic Cancellation
520 ///
521 /// Cancellation is **deterministic** - the orchestration fails at a well-defined point:
522 /// - Not mid-activity (activities complete)
523 /// - Not mid-turn (current turn finishes)
524 /// - Failure is recorded in history (replays consistently)
525 ///
526 /// # Propagation
527 ///
528 /// If the orchestration has child sub-orchestrations, they are also cancelled.
529 ///
530 /// # Example
531 ///
532 /// ```ignore
533 /// // Cancel a long-running order
534 /// client.cancel_instance("order-123", "Customer requested cancellation").await?;
535 ///
536 /// // Wait for cancellation to complete
537 /// let status = client.wait_for_orchestration("order-123", std::time::Duration::from_secs(5)).await?;
538 /// match status {
539 /// OrchestrationStatus::Failed { details, .. } if matches!(
540 /// details,
541 /// duroxide::ErrorDetails::Application {
542 /// kind: duroxide::AppErrorKind::Cancelled { .. },
543 /// ..
544 /// }
545 /// ) => {
546 /// println!("Successfully cancelled");
547 /// }
548 /// _ => {}
549 /// }
550 /// ```
551 ///
552 /// # Error Cases
553 ///
554 /// - Instance already completed: Cancellation is no-op
555 /// - Instance doesn't exist: Cancellation is no-op
556 ///
557 /// # Errors
558 ///
559 /// Returns `ClientError::Provider` if the provider fails to enqueue the cancellation.
560 pub async fn cancel_instance(
561 &self,
562 instance: impl Into<String>,
563 reason: impl Into<String>,
564 ) -> Result<(), ClientError> {
565 let item = WorkItem::CancelInstance {
566 instance: instance.into(),
567 reason: reason.into(),
568 };
569 self.store
570 .enqueue_for_orchestrator(item, None)
571 .await
572 .map_err(ClientError::from)
573 }
574
575 /// Get the current status of an orchestration by inspecting its history.
576 ///
577 /// # Purpose
578 ///
579 /// Query the current state of an orchestration instance without waiting.
580 ///
581 /// # Parameters
582 ///
583 /// * `instance` - Instance ID to query
584 ///
585 /// # Returns
586 ///
587 /// * `OrchestrationStatus::NotFound` - Instance doesn't exist
588 /// * `OrchestrationStatus::Running` - Instance is still executing
589 /// * `OrchestrationStatus::Completed { output, .. }` - Instance completed successfully
590 /// * `OrchestrationStatus::Failed { error }` - Instance failed (includes cancellations)
591 ///
592 /// # Behavior
593 ///
594 /// - Reads instance history from provider
595 /// - Scans for terminal events (Completed/Failed)
596 /// - For multi-execution instances (ContinueAsNew), returns status of LATEST execution
597 ///
598 /// # Performance
599 ///
600 /// This method reads from storage (not cached). For polling, use `wait_for_orchestration` instead.
601 ///
602 /// # Example
603 ///
604 /// ```rust,no_run
605 /// # use duroxide::{Client, ClientError, OrchestrationStatus};
606 /// # async fn example(client: Client) -> Result<(), ClientError> {
607 /// let status = client.get_orchestration_status("order-123").await?;
608 ///
609 /// match status {
610 /// OrchestrationStatus::NotFound => println!("Instance not found"),
611 /// OrchestrationStatus::Running { .. } => println!("Still processing"),
612 /// OrchestrationStatus::Completed { output, .. } => println!("Done: {}", output),
613 /// OrchestrationStatus::Failed { details, .. } => eprintln!("Error: {}", details.display_message()),
614 /// }
615 /// # Ok(())
616 /// # }
617 /// ```
618 ///
619 /// # Errors
620 ///
621 /// Returns `ClientError::Provider` if the provider fails to read the orchestration history.
622 pub async fn get_orchestration_status(&self, instance: &str) -> Result<OrchestrationStatus, ClientError> {
623 let hist = self.store.read(instance).await.map_err(ClientError::from)?;
624
625 // Query custom status (lightweight, always available)
626 let (custom_status, custom_status_version) = match self.store.get_custom_status(instance, 0).await {
627 Ok(Some((cs, v))) => (cs, v),
628 Ok(None) => (None, 0),
629 Err(_) => (None, 0), // Best-effort: don't fail status query for custom_status errors
630 };
631
632 // Find terminal events first
633 for e in hist.iter().rev() {
634 match &e.kind {
635 EventKind::OrchestrationCompleted { output } => {
636 return Ok(OrchestrationStatus::Completed {
637 output: output.clone(),
638 custom_status,
639 custom_status_version,
640 });
641 }
642 EventKind::OrchestrationFailed { details } => {
643 return Ok(OrchestrationStatus::Failed {
644 details: details.clone(),
645 custom_status,
646 custom_status_version,
647 });
648 }
649 _ => {}
650 }
651 }
652 // If we ever saw a start, it's running
653 if hist
654 .iter()
655 .any(|e| matches!(&e.kind, EventKind::OrchestrationStarted { .. }))
656 {
657 Ok(OrchestrationStatus::Running {
658 custom_status,
659 custom_status_version,
660 })
661 } else {
662 Ok(OrchestrationStatus::NotFound)
663 }
664 }
665
666 /// Wait until terminal state or timeout using provider reads.
667 ///
668 /// # Purpose
669 ///
670 /// Poll for orchestration completion with exponential backoff, returning when terminal or timeout.
671 ///
672 /// # Parameters
673 ///
674 /// * `instance` - Instance ID to wait for
675 /// * `timeout` - Maximum time to wait before returning timeout error
676 ///
677 /// # Returns
678 ///
679 /// * `Ok(OrchestrationStatus::Completed { output, .. })` - Orchestration completed successfully
680 /// * `Ok(OrchestrationStatus::Failed { details, .. })` - Orchestration failed (includes cancellations)
681 /// * `Err(ClientError::Timeout)` - Timeout elapsed while still Running
682 /// * `Err(ClientError::Provider(e))` - Provider/Storage error
683 ///
684 /// **Note:** Never returns `NotFound` or `Running` - only terminal states or timeout.
685 ///
686 /// # Polling Behavior
687 ///
688 /// - First check: Immediate (no delay)
689 /// - Subsequent checks: Exponential backoff starting at 5ms, doubling each iteration, max 100ms
690 /// - Continues until terminal state or timeout
691 ///
692 /// # Example
693 ///
694 /// ```ignore
695 /// // Start orchestration
696 /// client.start_orchestration("order-123", "ProcessOrder", "{}").await?;
697 ///
698 /// // Wait up to 30 seconds
699 /// match client.wait_for_orchestration("order-123", std::time::Duration::from_secs(30)).await {
700 /// Ok(OrchestrationStatus::Completed { output, .. }) => {
701 /// println!("Success: {}", output);
702 /// }
703 /// Ok(OrchestrationStatus::Failed { details, .. }) => {
704 /// eprintln!("Failed ({}): {}", details.category(), details.display_message());
705 /// }
706 /// Err(ClientError::Timeout) => {
707 /// println!("Still running after 30s, instance: order-123");
708 /// // Instance is still running - can wait more or cancel
709 /// }
710 /// _ => unreachable!("wait_for_orchestration only returns terminal or timeout"),
711 /// }
712 /// ```
713 ///
714 /// # Use Cases
715 ///
716 /// - Synchronous request/response workflows
717 /// - Testing (wait for workflow to complete)
718 /// - CLI tools (block until done)
719 /// - Health checks
720 ///
721 /// # For Long-Running Workflows
722 ///
723 /// Don't wait for hours/days:
724 /// ```ignore
725 /// // Start workflow
726 /// client.start_orchestration("batch-job", "ProcessBatch", "{}").await.unwrap();
727 ///
728 /// // DON'T wait for hours
729 /// // let status = client.wait_for_orchestration("batch-job", Duration::from_hours(24)).await;
730 ///
731 /// // DO poll periodically
732 /// loop {
733 /// match client.get_orchestration_status("batch-job").await {
734 /// OrchestrationStatus::Completed { .. } => break,
735 /// OrchestrationStatus::Failed { .. } => break,
736 /// _ => tokio::time::sleep(std::time::Duration::from_secs(60)).await,
737 /// }
738 /// }
739 /// ```
740 ///
741 /// # Errors
742 ///
743 /// Returns `ClientError::Provider` if the provider fails to read the orchestration status.
744 /// Returns `ClientError::Timeout` if the orchestration doesn't complete within the timeout.
745 pub async fn wait_for_orchestration(
746 &self,
747 instance: &str,
748 timeout: std::time::Duration,
749 ) -> Result<OrchestrationStatus, ClientError> {
750 let deadline = std::time::Instant::now() + timeout;
751 // quick path
752 match self.get_orchestration_status(instance).await {
753 Ok(s @ OrchestrationStatus::Completed { .. }) => return Ok(s),
754 Ok(s @ OrchestrationStatus::Failed { .. }) => return Ok(s),
755 Err(e) => return Err(e),
756 _ => {}
757 }
758 // poll with backoff
759 let mut delay_ms: u64 = INITIAL_POLL_DELAY_MS;
760 while std::time::Instant::now() < deadline {
761 match self.get_orchestration_status(instance).await {
762 Ok(s @ OrchestrationStatus::Completed { .. }) => return Ok(s),
763 Ok(s @ OrchestrationStatus::Failed { .. }) => return Ok(s),
764 Err(e) => return Err(e),
765 _ => {
766 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
767 delay_ms = (delay_ms.saturating_mul(POLL_DELAY_MULTIPLIER)).min(MAX_POLL_DELAY_MS);
768 }
769 }
770 }
771 Err(ClientError::Timeout)
772 }
773
774 /// Typed wait helper: decodes output on Completed, returns Err(String) on Failed.
775 ///
776 /// # Errors
777 ///
778 /// Returns `ClientError::Provider` if the provider fails to read the orchestration status.
779 /// Returns `ClientError::Timeout` if the orchestration doesn't complete within the timeout.
780 /// Returns `ClientError::InvalidInput` if deserialization of the output fails.
781 pub async fn wait_for_orchestration_typed<Out: serde::de::DeserializeOwned>(
782 &self,
783 instance: &str,
784 timeout: std::time::Duration,
785 ) -> Result<Result<Out, String>, ClientError> {
786 match self.wait_for_orchestration(instance, timeout).await? {
787 OrchestrationStatus::Completed { output, .. } => match Json::decode::<Out>(&output) {
788 Ok(v) => Ok(Ok(v)),
789 Err(e) => Err(ClientError::InvalidInput {
790 message: format!("decode failed: {e}"),
791 }),
792 },
793 OrchestrationStatus::Failed { details, .. } => Ok(Err(details.display_message())),
794 _ => unreachable!("wait_for_orchestration returns only terminal or timeout"),
795 }
796 }
797
798 /// Wait for custom_status to change, polling the provider at `poll_interval`.
799 ///
800 /// Returns the full `OrchestrationStatus` when:
801 /// - The `custom_status_version` exceeds `last_seen_version`
802 /// - The orchestration reaches a terminal state (Completed/Failed)
803 /// - The timeout elapses (returns `Err(ClientError::Timeout)`)
804 ///
805 /// # Parameters
806 ///
807 /// * `instance` - Instance ID to monitor
808 /// * `last_seen_version` - The version the caller last observed (0 to get any status)
809 /// * `poll_interval` - How often to check the provider
810 /// * `timeout` - Maximum time to wait
811 ///
812 /// # Example
813 ///
814 /// ```ignore
815 /// let mut version = 0u64;
816 /// loop {
817 /// match client.wait_for_status_change("order-123", version, Duration::from_millis(200), Duration::from_secs(30)).await {
818 /// Ok(OrchestrationStatus::Running { custom_status, custom_status_version }) => {
819 /// println!("Progress: {:?}", custom_status);
820 /// version = custom_status_version;
821 /// }
822 /// Ok(OrchestrationStatus::Completed { output, .. }) => {
823 /// println!("Done: {output}");
824 /// break;
825 /// }
826 /// _ => break,
827 /// }
828 /// }
829 /// ```
830 ///
831 /// # Errors
832 ///
833 /// Returns [`ClientError`] if the provider fails or the instance doesn't exist.
834 pub async fn wait_for_status_change(
835 &self,
836 instance: &str,
837 last_seen_version: u64,
838 poll_interval: std::time::Duration,
839 timeout: std::time::Duration,
840 ) -> Result<OrchestrationStatus, ClientError> {
841 let deadline = std::time::Instant::now() + timeout;
842
843 while std::time::Instant::now() < deadline {
844 // Lightweight check: just custom_status + version
845 match self.store.get_custom_status(instance, last_seen_version).await {
846 Ok(Some(_)) => {
847 // Version changed — return full status
848 return self.get_orchestration_status(instance).await;
849 }
850 Ok(None) => {
851 // No change — check if terminal before sleeping
852 // (get_custom_status returns None if version hasn't changed,
853 // but also if the instance doesn't exist or is terminal)
854 }
855 Err(e) => return Err(ClientError::from(e)),
856 }
857
858 // Also check for terminal state (in case orchestration completed
859 // without ever updating custom_status)
860 match self.get_orchestration_status(instance).await? {
861 OrchestrationStatus::Running { .. } | OrchestrationStatus::NotFound => {
862 // Still running — sleep and retry
863 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
864 tokio::time::sleep(poll_interval.min(remaining)).await;
865 }
866 terminal => return Ok(terminal),
867 }
868 }
869
870 Err(ClientError::Timeout)
871 }
872
873 // ===== Key-Value Store =====
874
875 /// Read a single KV entry for a given instance.
876 ///
877 /// Reads directly from the provider's materialized `kv_store` table.
878 /// Returns `Ok(None)` if the key doesn't exist.
879 ///
880 /// # Errors
881 ///
882 /// Returns `ClientError` if the provider fails to read.
883 pub async fn get_kv_value(&self, instance: &str, key: &str) -> Result<Option<String>, ClientError> {
884 self.store.get_kv_value(instance, key).await.map_err(ClientError::from)
885 }
886
887 /// Read a typed KV entry for a given instance.
888 ///
889 /// Deserializes the stored JSON value into `T`.
890 /// Returns `Ok(None)` if the key doesn't exist.
891 ///
892 /// # Errors
893 ///
894 /// Returns `ClientError` if the provider fails or deserialization fails.
895 pub async fn get_kv_value_typed<T: serde::de::DeserializeOwned>(
896 &self,
897 instance: &str,
898 key: &str,
899 ) -> Result<Option<T>, ClientError> {
900 match self.get_kv_value(instance, key).await? {
901 None => Ok(None),
902 Some(s) => serde_json::from_str(&s)
903 .map(Some)
904 .map_err(|e| ClientError::InvalidInput {
905 message: format!("KV deserialization error: {e}"),
906 }),
907 }
908 }
909
910 /// Read all KV entries for a given instance.
911 ///
912 /// Returns a map of key→value pairs. Returns an empty map if no keys exist.
913 ///
914 /// # Errors
915 ///
916 /// Returns `ClientError` if the provider fails to read.
917 pub async fn get_kv_all_values(
918 &self,
919 instance: &str,
920 ) -> Result<std::collections::HashMap<String, String>, ClientError> {
921 self.store.get_kv_all_values(instance).await.map_err(ClientError::from)
922 }
923
924 /// Poll a KV key until it becomes `Some`, or timeout.
925 ///
926 /// Uses exponential backoff (same as [`wait_for_orchestration`](Self::wait_for_orchestration)).
927 ///
928 /// # Example
929 ///
930 /// ```ignore
931 /// let value = client.wait_for_kv_value(
932 /// "my-instance", "response:op-1",
933 /// Duration::from_secs(5),
934 /// ).await?;
935 /// println!("Got: {value}");
936 /// ```
937 ///
938 /// # Errors
939 ///
940 /// Returns `ClientError::Timeout` if the key is still `None` after `timeout`.
941 /// Returns `ClientError` if a provider read fails.
942 pub async fn wait_for_kv_value(
943 &self,
944 instance: &str,
945 key: &str,
946 timeout: std::time::Duration,
947 ) -> Result<String, ClientError> {
948 let deadline = std::time::Instant::now() + timeout;
949 // quick path
950 if let Some(val) = self.get_kv_value(instance, key).await? {
951 return Ok(val);
952 }
953 // poll with backoff
954 let mut delay_ms: u64 = INITIAL_POLL_DELAY_MS;
955 while std::time::Instant::now() < deadline {
956 if let Some(val) = self.get_kv_value(instance, key).await? {
957 return Ok(val);
958 }
959 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
960 delay_ms = (delay_ms.saturating_mul(POLL_DELAY_MULTIPLIER)).min(MAX_POLL_DELAY_MS);
961 }
962 Err(ClientError::Timeout)
963 }
964
965 /// Poll a typed KV key until it becomes `Some`, or timeout.
966 ///
967 /// Same as [`wait_for_kv_value`](Self::wait_for_kv_value) but deserializes
968 /// the JSON value into `T`.
969 ///
970 /// # Errors
971 ///
972 /// Returns `ClientError::Timeout` if the key is still `None` after `timeout`.
973 /// Returns `ClientError` if a provider read or deserialization fails.
974 pub async fn wait_for_kv_value_typed<T: serde::de::DeserializeOwned>(
975 &self,
976 instance: &str,
977 key: &str,
978 timeout: std::time::Duration,
979 ) -> Result<T, ClientError> {
980 let raw = self.wait_for_kv_value(instance, key, timeout).await?;
981 serde_json::from_str(&raw).map_err(|e| ClientError::InvalidInput {
982 message: format!("KV deserialization error: {e}"),
983 })
984 }
985
986 // ===== Capability Discovery =====
987
988 /// Check if management capabilities are available.
989 ///
990 /// # Returns
991 ///
992 /// `true` if the provider implements `ProviderAdmin`, `false` otherwise.
993 ///
994 /// # Usage
995 ///
996 /// ```ignore
997 /// let client = Client::new(provider);
998 /// if client.has_management_capability() {
999 /// let instances = client.list_all_instances().await?;
1000 /// } else {
1001 /// println!("Management features not available");
1002 /// }
1003 /// ```
1004 pub fn has_management_capability(&self) -> bool {
1005 self.discover_management().is_ok()
1006 }
1007
1008 /// Automatically discover management capabilities from the provider.
1009 ///
1010 /// # Returns
1011 ///
1012 /// `Ok(&dyn ManagementCapability)` if available, `Err(String)` if not.
1013 ///
1014 /// # Internal Use
1015 ///
1016 /// This method is used internally by management methods to access capabilities.
1017 fn discover_management(&self) -> Result<&dyn ProviderAdmin, ClientError> {
1018 self.store
1019 .as_management_capability()
1020 .ok_or(ClientError::ManagementNotAvailable)
1021 }
1022
1023 // ===== Rich Management Methods =====
1024
1025 /// Read runtime introspection stats for an orchestration instance.
1026 ///
1027 /// Returns [`SystemStats`](crate::SystemStats) containing
1028 /// history size, event queue depth, KV usage, etc.
1029 ///
1030 /// Returns `Ok(None)` if the instance doesn't exist.
1031 ///
1032 /// # Errors
1033 ///
1034 /// Returns [`ClientError`] if the provider query fails.
1035 pub async fn get_orchestration_stats(&self, instance: &str) -> Result<Option<crate::SystemStats>, ClientError> {
1036 self.store.get_instance_stats(instance).await.map_err(ClientError::from)
1037 }
1038
1039 /// List all orchestration instances.
1040 ///
1041 /// # Returns
1042 ///
1043 /// Vector of instance IDs, typically sorted by creation time (newest first).
1044 ///
1045 /// # Errors
1046 ///
1047 /// Returns `Err("Management features not available")` if the provider doesn't implement `ProviderAdmin`.
1048 ///
1049 /// # Usage
1050 ///
1051 /// ```ignore
1052 /// let client = Client::new(provider);
1053 /// if client.has_management_capability() {
1054 /// let instances = client.list_all_instances().await?;
1055 /// for instance in instances {
1056 /// println!("Instance: {}", instance);
1057 /// }
1058 /// }
1059 /// ```
1060 pub async fn list_all_instances(&self) -> Result<Vec<String>, ClientError> {
1061 self.discover_management()?
1062 .list_instances()
1063 .await
1064 .map_err(ClientError::from)
1065 }
1066
1067 /// List instances matching a status filter.
1068 ///
1069 /// # Parameters
1070 ///
1071 /// * `status` - Filter by execution status: "Running", "Completed", "Failed", "ContinuedAsNew"
1072 ///
1073 /// # Returns
1074 ///
1075 /// Vector of instance IDs with the specified status.
1076 ///
1077 /// # Errors
1078 ///
1079 /// Returns `Err("Management features not available")` if the provider doesn't implement `ProviderAdmin`.
1080 ///
1081 /// # Usage
1082 ///
1083 /// ```ignore
1084 /// let client = Client::new(provider);
1085 /// if client.has_management_capability() {
1086 /// let running = client.list_instances_by_status("Running").await?;
1087 /// let completed = client.list_instances_by_status("Completed").await?;
1088 /// println!("Running: {}, Completed: {}", running.len(), completed.len());
1089 /// }
1090 /// ```
1091 pub async fn list_instances_by_status(&self, status: &str) -> Result<Vec<String>, ClientError> {
1092 self.discover_management()?
1093 .list_instances_by_status(status)
1094 .await
1095 .map_err(ClientError::from)
1096 }
1097
1098 /// Get comprehensive information about an instance.
1099 ///
1100 /// # Parameters
1101 ///
1102 /// * `instance` - The ID of the orchestration instance.
1103 ///
1104 /// # Returns
1105 ///
1106 /// Detailed instance information including status, output, and metadata.
1107 ///
1108 /// # Errors
1109 ///
1110 /// Returns `Err("Management features not available")` if the provider doesn't implement `ProviderAdmin`.
1111 ///
1112 /// # Usage
1113 ///
1114 /// ```ignore
1115 /// let client = Client::new(provider);
1116 /// if client.has_management_capability() {
1117 /// let info = client.get_instance_info("order-123").await?;
1118 /// println!("Instance {}: {} ({})", info.instance_id, info.orchestration_name, info.status);
1119 /// }
1120 /// ```
1121 pub async fn get_instance_info(&self, instance: &str) -> Result<InstanceInfo, ClientError> {
1122 self.discover_management()?
1123 .get_instance_info(instance)
1124 .await
1125 .map_err(ClientError::from)
1126 }
1127
1128 /// Get detailed information about a specific execution.
1129 ///
1130 /// # Parameters
1131 ///
1132 /// * `instance` - The ID of the orchestration instance.
1133 /// * `execution_id` - The specific execution ID.
1134 ///
1135 /// # Returns
1136 ///
1137 /// Detailed execution information including status, output, and event count.
1138 ///
1139 /// # Errors
1140 ///
1141 /// Returns `Err("Management features not available")` if the provider doesn't implement `ProviderAdmin`.
1142 ///
1143 /// # Usage
1144 ///
1145 /// ```ignore
1146 /// let client = Client::new(provider);
1147 /// if client.has_management_capability() {
1148 /// let info = client.get_execution_info("order-123", 1).await?;
1149 /// println!("Execution {}: {} events, status: {}", info.execution_id, info.event_count, info.status);
1150 /// }
1151 /// ```
1152 pub async fn get_execution_info(&self, instance: &str, execution_id: u64) -> Result<ExecutionInfo, ClientError> {
1153 self.discover_management()?
1154 .get_execution_info(instance, execution_id)
1155 .await
1156 .map_err(ClientError::from)
1157 }
1158
1159 /// List all execution IDs for an instance.
1160 ///
1161 /// Returns execution IDs in ascending order: \[1\], \[1, 2\], \[1, 2, 3\], etc.
1162 /// Each execution represents either the initial run or a continuation via ContinueAsNew.
1163 ///
1164 /// # Parameters
1165 ///
1166 /// * `instance` - The instance ID to query
1167 ///
1168 /// # Returns
1169 ///
1170 /// Vector of execution IDs in ascending order.
1171 ///
1172 /// # Errors
1173 ///
1174 /// Returns an error if:
1175 /// - The provider doesn't support management capabilities
1176 /// - The database query fails
1177 ///
1178 /// # Usage
1179 ///
1180 /// ```ignore
1181 /// let client = Client::new(provider);
1182 /// if client.has_management_capability() {
1183 /// let executions = client.list_executions("order-123").await?;
1184 /// println!("Instance has {} executions", executions.len()); // [1, 2, 3]
1185 /// }
1186 /// ```
1187 pub async fn list_executions(&self, instance: &str) -> Result<Vec<u64>, ClientError> {
1188 let mgmt = self.discover_management()?;
1189 mgmt.list_executions(instance).await.map_err(ClientError::from)
1190 }
1191
1192 /// Read the full event history for a specific execution within an instance.
1193 ///
1194 /// Returns all events for the specified execution in chronological order.
1195 /// Each execution has its own independent history starting from OrchestrationStarted.
1196 ///
1197 /// # Parameters
1198 ///
1199 /// * `instance` - The instance ID
1200 /// * `execution_id` - The specific execution ID (starts at 1)
1201 ///
1202 /// # Returns
1203 ///
1204 /// Vector of events in chronological order (oldest first).
1205 ///
1206 /// # Errors
1207 ///
1208 /// Returns an error if:
1209 /// - The provider doesn't support management capabilities
1210 /// - The instance or execution doesn't exist
1211 /// - The database query fails
1212 ///
1213 /// # Usage
1214 ///
1215 /// ```ignore
1216 /// let client = Client::new(provider);
1217 /// if client.has_management_capability() {
1218 /// let history = client.read_execution_history("order-123", 1).await?;
1219 /// for event in history {
1220 /// println!("Event: {:?}", event);
1221 /// }
1222 /// }
1223 /// ```
1224 pub async fn read_execution_history(
1225 &self,
1226 instance: &str,
1227 execution_id: u64,
1228 ) -> Result<Vec<crate::Event>, ClientError> {
1229 let mgmt = self.discover_management()?;
1230 mgmt.read_history_with_execution_id(instance, execution_id)
1231 .await
1232 .map_err(ClientError::from)
1233 }
1234
1235 /// Get system-wide metrics for the orchestration engine.
1236 ///
1237 /// # Returns
1238 ///
1239 /// System metrics including instance counts, execution counts, and status breakdown.
1240 ///
1241 /// # Errors
1242 ///
1243 /// Returns `Err("Management features not available")` if the provider doesn't implement `ProviderAdmin`.
1244 ///
1245 /// # Usage
1246 ///
1247 /// ```ignore
1248 /// let client = Client::new(provider);
1249 /// if client.has_management_capability() {
1250 /// let metrics = client.get_system_metrics().await?;
1251 /// println!("System health: {} running, {} completed, {} failed",
1252 /// metrics.running_instances, metrics.completed_instances, metrics.failed_instances);
1253 /// }
1254 /// ```
1255 pub async fn get_system_metrics(&self) -> Result<SystemMetrics, ClientError> {
1256 self.discover_management()?
1257 .get_system_metrics()
1258 .await
1259 .map_err(ClientError::from)
1260 }
1261
1262 /// Get the current depths of the internal work queues.
1263 ///
1264 /// # Returns
1265 ///
1266 /// Queue depths for orchestrator, worker, and timer queues.
1267 ///
1268 /// # Errors
1269 ///
1270 /// Returns `Err("Management features not available")` if the provider doesn't implement `ProviderAdmin`.
1271 ///
1272 /// # Usage
1273 ///
1274 /// ```ignore
1275 /// let client = Client::new(provider);
1276 /// if client.has_management_capability() {
1277 /// let queues = client.get_queue_depths().await?;
1278 /// println!("Queue depths - Orchestrator: {}, Worker: {}, Timer: {}",
1279 /// queues.orchestrator_queue, queues.worker_queue, queues.timer_queue);
1280 /// }
1281 /// ```
1282 pub async fn get_queue_depths(&self) -> Result<QueueDepths, ClientError> {
1283 self.discover_management()?
1284 .get_queue_depths()
1285 .await
1286 .map_err(ClientError::from)
1287 }
1288
1289 // ===== Hierarchy Operations =====
1290
1291 /// Get the full instance tree rooted at the given instance.
1292 ///
1293 /// Returns all instances in the tree: the root, all children, grandchildren, etc.
1294 /// Useful for inspecting hierarchy before deletion, or for understanding
1295 /// sub-orchestration relationships.
1296 ///
1297 /// # Parameters
1298 ///
1299 /// * `instance_id` - The root instance ID to start from.
1300 ///
1301 /// # Errors
1302 ///
1303 /// Returns [`ClientError::InstanceNotFound`] if the instance doesn't exist.
1304 /// Returns [`ClientError::ProviderError`] for database/connection issues.
1305 ///
1306 /// # Example
1307 ///
1308 /// ```rust,no_run
1309 /// # use duroxide::{Client, ClientError};
1310 /// # async fn example(client: Client) -> Result<(), ClientError> {
1311 /// let tree = client.get_instance_tree("order-123").await?;
1312 /// println!("Will delete {} instances", tree.size());
1313 /// for id in &tree.all_ids {
1314 /// println!(" - {}", id);
1315 /// }
1316 /// client.delete_instance("order-123", false).await?;
1317 /// # Ok(())
1318 /// # }
1319 /// ```
1320 pub async fn get_instance_tree(&self, instance_id: &str) -> Result<InstanceTree, ClientError> {
1321 let mgmt = self.discover_management()?;
1322 mgmt.get_instance_tree(instance_id).await.map_err(ClientError::from)
1323 }
1324
1325 // ===== Deletion and Pruning Operations =====
1326
1327 /// Delete an orchestration instance and all its associated data.
1328 ///
1329 /// This removes the instance, all executions, all history events, and any
1330 /// pending queue messages (orchestrator, worker, timer).
1331 ///
1332 /// # Parameters
1333 ///
1334 /// * `instance_id` - The ID of the instance to delete.
1335 /// * `force` - If true, delete even if the instance is in Running state.
1336 /// WARNING: Force delete only removes database state; it does NOT cancel
1337 /// in-flight tokio tasks. Use `cancel_instance` first for graceful termination.
1338 ///
1339 /// # Errors
1340 ///
1341 /// - [`ClientError::InstanceStillRunning`] - Instance is running and force=false.
1342 /// - [`ClientError::CannotDeleteSubOrchestration`] - Instance is a sub-orchestration.
1343 /// - [`ClientError::InstanceNotFound`] - Instance doesn't exist.
1344 /// - [`ClientError::ProviderError`] - Database/connection issues.
1345 ///
1346 /// # Cascading Delete
1347 ///
1348 /// If the instance is a root orchestration with sub-orchestrations, all descendants
1349 /// are included in the deletion (performed atomically in a single transaction).
1350 ///
1351 /// # Example
1352 ///
1353 /// ```rust,no_run
1354 /// # use duroxide::{Client, ClientError};
1355 /// # async fn example(client: Client) -> Result<(), ClientError> {
1356 /// // Delete a completed instance
1357 /// let result = client.delete_instance("order-123", false).await?;
1358 /// println!("Deleted {} events", result.events_deleted);
1359 ///
1360 /// // Graceful pattern: cancel first, then delete
1361 /// client.cancel_instance("workflow-456", "cleanup").await?;
1362 /// // Wait for cancellation to complete...
1363 /// client.delete_instance("workflow-456", false).await?;
1364 ///
1365 /// // Force delete an instance stuck in Running state
1366 /// client.delete_instance("stuck-workflow", true).await?;
1367 /// # Ok(())
1368 /// # }
1369 /// ```
1370 pub async fn delete_instance(&self, instance_id: &str, force: bool) -> Result<DeleteInstanceResult, ClientError> {
1371 let mgmt = self.discover_management()?;
1372 let result = mgmt
1373 .delete_instance(instance_id, force)
1374 .await
1375 .map_err(|e| Self::translate_delete_error(e, instance_id))?;
1376
1377 info!(
1378 instance_id = %instance_id,
1379 force = %force,
1380 instances_deleted = %result.instances_deleted,
1381 executions_deleted = %result.executions_deleted,
1382 events_deleted = %result.events_deleted,
1383 queue_messages_deleted = %result.queue_messages_deleted,
1384 "Deleted instance"
1385 );
1386
1387 Ok(result)
1388 }
1389
1390 /// Delete multiple orchestration instances matching the filter criteria.
1391 ///
1392 /// Only instances in terminal states (Completed, Failed) are eligible.
1393 /// Running instances are silently skipped (not an error).
1394 ///
1395 /// # Parameters
1396 ///
1397 /// * `filter` - Criteria for selecting instances to delete. All criteria are ANDed.
1398 ///
1399 /// # Filter Behavior
1400 ///
1401 /// - `instance_ids`: Allowlist of specific IDs to consider
1402 /// - `completed_before`: Only delete instances completed before this timestamp (ms since epoch)
1403 /// - `limit`: Maximum number of instances to delete (default: 1000)
1404 ///
1405 /// # Errors
1406 ///
1407 /// Returns [`ClientError::ProviderError`] for database/connection issues.
1408 ///
1409 /// # Example
1410 ///
1411 /// ```rust,no_run
1412 /// # use duroxide::{Client, ClientError, InstanceFilter};
1413 /// # async fn example(client: Client, now_ms: u64) -> Result<(), ClientError> {
1414 /// // Delete specific instances
1415 /// let result = client.delete_instance_bulk(InstanceFilter {
1416 /// instance_ids: Some(vec!["order-1".into(), "order-2".into()]),
1417 /// ..Default::default()
1418 /// }).await?;
1419 ///
1420 /// // Delete by age (retention policy)
1421 /// let five_days_ago = now_ms - (5 * 24 * 60 * 60 * 1000);
1422 /// let result = client.delete_instance_bulk(InstanceFilter {
1423 /// completed_before: Some(five_days_ago),
1424 /// limit: Some(500),
1425 /// ..Default::default()
1426 /// }).await?;
1427 /// # Ok(())
1428 /// # }
1429 /// ```
1430 pub async fn delete_instance_bulk(&self, filter: InstanceFilter) -> Result<DeleteInstanceResult, ClientError> {
1431 let result = self
1432 .discover_management()?
1433 .delete_instance_bulk(filter.clone())
1434 .await
1435 .map_err(ClientError::from)?;
1436
1437 info!(
1438 filter = ?filter,
1439 instances_deleted = %result.instances_deleted,
1440 executions_deleted = %result.executions_deleted,
1441 events_deleted = %result.events_deleted,
1442 queue_messages_deleted = %result.queue_messages_deleted,
1443 "Bulk deleted instances"
1444 );
1445
1446 Ok(result)
1447 }
1448
1449 /// Prune old executions from a single long-running instance.
1450 ///
1451 /// Use this for `ContinueAsNew` workflows that accumulate many executions
1452 /// over time. The current (active) execution is never pruned.
1453 ///
1454 /// # Parameters
1455 ///
1456 /// * `instance_id` - The instance to prune executions from.
1457 /// * `options` - Criteria for selecting executions to delete. All criteria are ANDed.
1458 ///
1459 /// # Options Behavior
1460 ///
1461 /// - `keep_last`: Keep the last N executions by execution_id
1462 /// - `completed_before`: Only delete executions completed before this timestamp (ms)
1463 /// - Running executions are NEVER pruned
1464 /// - The current execution is NEVER pruned
1465 ///
1466 /// # Errors
1467 ///
1468 /// Returns [`ClientError::ProviderError`] for database/connection issues.
1469 ///
1470 /// # Example
1471 ///
1472 /// ```rust,no_run
1473 /// # use duroxide::{Client, ClientError, PruneOptions};
1474 /// # async fn example(client: Client, now_ms: u64) -> Result<(), ClientError> {
1475 /// // Keep only the last 10 executions
1476 /// let result = client.prune_executions("eternal-workflow", PruneOptions {
1477 /// keep_last: Some(10),
1478 /// ..Default::default()
1479 /// }).await?;
1480 ///
1481 /// // Delete executions older than 30 days
1482 /// let thirty_days_ago = now_ms - (30 * 24 * 60 * 60 * 1000);
1483 /// let result = client.prune_executions("eternal-workflow", PruneOptions {
1484 /// completed_before: Some(thirty_days_ago),
1485 /// ..Default::default()
1486 /// }).await?;
1487 /// # Ok(())
1488 /// # }
1489 /// ```
1490 pub async fn prune_executions(&self, instance_id: &str, options: PruneOptions) -> Result<PruneResult, ClientError> {
1491 let result = self
1492 .discover_management()?
1493 .prune_executions(instance_id, options.clone())
1494 .await
1495 .map_err(ClientError::from)?;
1496
1497 info!(
1498 instance_id = %instance_id,
1499 options = ?options,
1500 executions_deleted = %result.executions_deleted,
1501 events_deleted = %result.events_deleted,
1502 instances_processed = %result.instances_processed,
1503 "Pruned executions"
1504 );
1505
1506 Ok(result)
1507 }
1508
1509 /// Prune old executions from multiple instances matching the filter.
1510 ///
1511 /// Applies the same prune options to all matching instances.
1512 ///
1513 /// # Parameters
1514 ///
1515 /// * `filter` - Criteria for selecting instances to process.
1516 /// * `options` - Criteria for selecting executions to delete within each instance.
1517 ///
1518 /// # Errors
1519 ///
1520 /// Returns [`ClientError::ProviderError`] for database/connection issues.
1521 ///
1522 /// # Example
1523 ///
1524 /// ```rust,no_run
1525 /// # use duroxide::{Client, ClientError, InstanceFilter, PruneOptions};
1526 /// # async fn example(client: Client) -> Result<(), ClientError> {
1527 /// // Prune all terminal instances: keep last 10 executions each
1528 /// let result = client.prune_executions_bulk(
1529 /// InstanceFilter { limit: Some(100), ..Default::default() },
1530 /// PruneOptions { keep_last: Some(10), ..Default::default() },
1531 /// ).await?;
1532 /// println!("Pruned {} executions across {} instances",
1533 /// result.executions_deleted, result.instances_processed);
1534 /// # Ok(())
1535 /// # }
1536 /// ```
1537 pub async fn prune_executions_bulk(
1538 &self,
1539 filter: InstanceFilter,
1540 options: PruneOptions,
1541 ) -> Result<PruneResult, ClientError> {
1542 let result = self
1543 .discover_management()?
1544 .prune_executions_bulk(filter.clone(), options.clone())
1545 .await
1546 .map_err(ClientError::from)?;
1547
1548 info!(
1549 filter = ?filter,
1550 options = ?options,
1551 executions_deleted = %result.executions_deleted,
1552 events_deleted = %result.events_deleted,
1553 instances_processed = %result.instances_processed,
1554 "Bulk pruned executions"
1555 );
1556
1557 Ok(result)
1558 }
1559
1560 /// Translate provider errors into more specific client errors for delete operations.
1561 fn translate_delete_error(e: ProviderError, instance_id: &str) -> ClientError {
1562 let msg = e.to_string().to_lowercase();
1563 if msg.contains("not found") {
1564 ClientError::InstanceNotFound {
1565 instance_id: instance_id.to_string(),
1566 }
1567 } else if msg.contains("still running") {
1568 ClientError::InstanceStillRunning {
1569 instance_id: instance_id.to_string(),
1570 }
1571 } else if msg.contains("sub-orchestration") || msg.contains("delete the root") {
1572 ClientError::CannotDeleteSubOrchestration {
1573 instance_id: instance_id.to_string(),
1574 }
1575 } else {
1576 ClientError::Provider(e)
1577 }
1578 }
1579}