Skip to main content

appcore_provider/
job.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: job.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/23 10:59:21 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/24 11:51:10 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use crate::{ProviderError, ProviderResult};
12use appcore_contracts::{CapabilityId, CoreId, JobId};
13
14/// Provider-neutral description of one durable Runtime job.
15#[derive(Clone, PartialEq, Eq)]
16pub struct JobSpec {
17    job_id: JobId,
18    capability: CapabilityId,
19    payload_reference: String,
20    available_at_ms: u64,
21    max_attempts: u32,
22}
23
24impl std::fmt::Debug for JobSpec {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        formatter
27            .debug_struct("JobSpec")
28            .field("job_id", &self.job_id)
29            .field("capability", &self.capability)
30            .field("payload_reference", &"REDACTED")
31            .field("available_at_ms", &self.available_at_ms)
32            .field("max_attempts", &self.max_attempts)
33            .finish()
34    }
35}
36
37impl JobSpec {
38    /// Creates a job containing an opaque external payload reference.
39    pub fn new(
40        job_id: JobId,
41        capability: CapabilityId,
42        payload_reference: impl Into<String>,
43        available_at_ms: u64,
44        max_attempts: u32,
45    ) -> ProviderResult<Self> {
46        let payload_reference = payload_reference.into();
47        validate_payload_reference(&payload_reference)?;
48        if max_attempts == 0 {
49            return Err(ProviderError::InvalidConfiguration(
50                "job max_attempts must be greater than zero".to_string(),
51            ));
52        }
53        Ok(Self {
54            job_id,
55            capability,
56            payload_reference,
57            available_at_ms,
58            max_attempts,
59        })
60    }
61
62    /// Returns the stable job identity.
63    pub fn job_id(&self) -> &JobId {
64        &self.job_id
65    }
66
67    /// Returns the capability required to execute the job.
68    pub fn capability(&self) -> &CapabilityId {
69        &self.capability
70    }
71
72    /// Returns the opaque provider-owned payload reference.
73    pub fn payload_reference(&self) -> &str {
74        &self.payload_reference
75    }
76
77    /// Returns the earliest execution timestamp.
78    pub fn available_at_ms(&self) -> u64 {
79        self.available_at_ms
80    }
81
82    /// Returns the bounded execution-attempt limit.
83    pub fn max_attempts(&self) -> u32 {
84        self.max_attempts
85    }
86}
87
88/// Fenced lease returned when a Runtime core claims a job.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct JobLease {
91    job_id: JobId,
92    holder_core_id: CoreId,
93    epoch: u64,
94    expires_at_ms: u64,
95}
96
97impl JobLease {
98    /// Creates a fenced job lease.
99    pub fn new(
100        job_id: JobId,
101        holder_core_id: CoreId,
102        epoch: u64,
103        expires_at_ms: u64,
104    ) -> ProviderResult<Self> {
105        if epoch == 0 || expires_at_ms == 0 {
106            return Err(ProviderError::InvalidConfiguration(
107                "job lease epoch and expiration must be greater than zero".to_string(),
108            ));
109        }
110        Ok(Self {
111            job_id,
112            holder_core_id,
113            epoch,
114            expires_at_ms,
115        })
116    }
117
118    /// Returns the leased job identity.
119    pub fn job_id(&self) -> &JobId {
120        &self.job_id
121    }
122
123    /// Returns the core holding the lease.
124    pub fn holder_core_id(&self) -> &CoreId {
125        &self.holder_core_id
126    }
127
128    /// Returns the monotonic fencing epoch.
129    pub fn epoch(&self) -> u64 {
130        self.epoch
131    }
132
133    /// Returns the lease expiration timestamp.
134    pub fn expires_at_ms(&self) -> u64 {
135        self.expires_at_ms
136    }
137}
138
139/// Controlled terminal or retry outcome for a claimed job.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum JobCompletion {
142    /// The job completed and must not be claimed again.
143    Completed,
144    /// The job failed permanently without exposing application error details.
145    Failed,
146    /// The job may be claimed again at the supplied timestamp.
147    RetryAt(u64),
148}
149
150/// Atomicity guarantee required from every durable job provider.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum JobAtomicity {
153    /// Submission is idempotent and claim/completion use an atomic fenced CAS.
154    FencedCompareAndSwap,
155}
156
157/// Provider contract for durable, capability-routed Runtime jobs.
158///
159/// Providers must offer at-least-once delivery. `submit` atomically inserts by
160/// `job_id` or confirms the equivalent existing job. `claim` atomically selects
161/// one eligible job, increments its fencing epoch, and stores its lease.
162/// `complete` atomically compares the full lease fence and applies exactly one
163/// terminal or retry transition. A stale or duplicate fence must be rejected.
164pub trait JobProvider: Send + Sync {
165    /// Reports the atomicity model implemented by this provider.
166    fn atomicity(&self) -> JobAtomicity {
167        JobAtomicity::FencedCompareAndSwap
168    }
169
170    /// Persists a new job idempotently by `job_id`.
171    fn submit(&self, job: JobSpec) -> ProviderResult<()>;
172
173    /// Claims the next eligible job for a capability and returns a fenced lease.
174    fn claim(
175        &self,
176        capability: &CapabilityId,
177        holder_core_id: &CoreId,
178        now_ms: u64,
179        lease_duration_ms: u64,
180    ) -> ProviderResult<Option<JobLease>>;
181
182    /// Completes or reschedules a job while enforcing the supplied lease fence.
183    fn complete(&self, lease: &JobLease, completion: JobCompletion) -> ProviderResult<()>;
184}
185
186fn validate_payload_reference(reference: &str) -> ProviderResult<()> {
187    if reference.trim().is_empty()
188        || reference.len() > 2_048
189        || reference.chars().any(char::is_control)
190    {
191        return Err(ProviderError::InvalidConfiguration(
192            "job payload reference is invalid".to_string(),
193        ));
194    }
195    Ok(())
196}