Skip to main content

awa_worker/
runtime.rs

1use std::collections::hash_map::DefaultHasher;
2use std::collections::HashMap;
3use std::hash::{Hash, Hasher};
4use std::sync::atomic::AtomicBool;
5use std::sync::Arc;
6use std::sync::RwLock;
7
8const IN_FLIGHT_SHARDS: usize = 64;
9
10pub(crate) type RunLease = i64;
11pub(crate) type InFlightKey = (i64, RunLease);
12pub(crate) type InFlightMap = Arc<InFlightRegistry>;
13type CancelFlag = Arc<AtomicBool>;
14type InFlightShard = RwLock<HashMap<InFlightKey, InFlightState>>;
15
16/// Per-job in-flight state: cancellation flag + progress buffer.
17pub(crate) struct InFlightState {
18    pub cancel: CancelFlag,
19    pub progress: Arc<std::sync::Mutex<ProgressState>>,
20}
21
22/// Mutable progress buffer shared between handler and heartbeat service.
23#[derive(Debug)]
24pub struct ProgressState {
25    /// Latest assembled progress value visible to handler code.
26    pub(crate) latest: Option<serde_json::Value>,
27    /// Generation counter — bumped on each handler-side mutation.
28    pub(crate) generation: u64,
29    /// Highest generation durably acknowledged by Postgres.
30    pub(crate) acked_generation: u64,
31    /// Snapshot currently being flushed by heartbeat, if any.
32    pub(crate) in_flight: Option<(u64, serde_json::Value)>,
33}
34
35impl ProgressState {
36    pub fn new(initial: Option<serde_json::Value>) -> Self {
37        Self {
38            latest: initial,
39            generation: 0,
40            acked_generation: 0,
41            in_flight: None,
42        }
43    }
44
45    /// Whether there is a pending update that has not been acked.
46    pub fn has_pending(&self) -> bool {
47        self.generation > self.acked_generation
48    }
49
50    /// Get a reference to the latest progress value.
51    pub fn latest(&self) -> Option<&serde_json::Value> {
52        self.latest.as_ref()
53    }
54
55    /// Clone the latest progress value.
56    pub fn clone_latest(&self) -> Option<serde_json::Value> {
57        self.latest.clone()
58    }
59
60    /// Set progress: percent (clamped 0-100), optional message.
61    /// Preserves existing metadata sub-object.
62    pub fn set_progress(&mut self, percent: u8, message: Option<&str>) {
63        let percent = percent.min(100);
64        let existing_metadata = self
65            .latest
66            .as_ref()
67            .and_then(|v| v.get("metadata"))
68            .cloned();
69
70        let mut value = serde_json::json!({ "percent": percent });
71        if let Some(msg) = message {
72            value["message"] = serde_json::Value::String(msg.to_string());
73        }
74        if let Some(meta) = existing_metadata {
75            value["metadata"] = meta;
76        }
77        self.latest = Some(value);
78        self.generation += 1;
79    }
80
81    /// Shallow-merge keys into the `metadata` sub-object.
82    /// Returns false if the existing metadata sub-object is a non-object type (no-op).
83    pub fn merge_metadata(&mut self, updates: &serde_json::Map<String, serde_json::Value>) -> bool {
84        let progress = self.latest.get_or_insert_with(|| serde_json::json!({}));
85        let metadata = progress
86            .as_object_mut()
87            .expect("progress is always an object")
88            .entry("metadata")
89            .or_insert_with(|| serde_json::json!({}));
90
91        if let Some(meta_obj) = metadata.as_object_mut() {
92            for (k, v) in updates {
93                meta_obj.insert(k.clone(), v.clone());
94            }
95            self.generation += 1;
96            true
97        } else {
98            false
99        }
100    }
101
102    /// Snapshot for flush: returns (value, generation) if there is a pending update.
103    pub fn pending_snapshot(&self) -> Option<(serde_json::Value, u64)> {
104        if self.acked_generation >= self.generation {
105            return None;
106        }
107        self.latest.as_ref().map(|v| (v.clone(), self.generation))
108    }
109
110    /// Advance acked_generation after a successful flush.
111    /// Only advances if `generation` is newer than current acked.
112    pub fn ack(&mut self, generation: u64) {
113        if generation > self.acked_generation {
114            self.acked_generation = generation;
115        }
116    }
117}
118
119pub(crate) struct InFlightRegistry {
120    shards: Box<[InFlightShard]>,
121}
122
123impl Default for InFlightRegistry {
124    fn default() -> Self {
125        let mut shards = Vec::with_capacity(IN_FLIGHT_SHARDS);
126        for _ in 0..IN_FLIGHT_SHARDS {
127            shards.push(RwLock::new(HashMap::new()));
128        }
129        Self {
130            shards: shards.into_boxed_slice(),
131        }
132    }
133}
134
135impl InFlightRegistry {
136    pub fn insert(&self, key: InFlightKey, state: InFlightState) {
137        let mut guard = self
138            .shard_for(&key)
139            .write()
140            .expect("in_flight shard poisoned");
141        guard.insert(key, state);
142    }
143
144    pub fn remove(&self, key: InFlightKey) -> Option<InFlightState> {
145        let mut guard = self
146            .shard_for(&key)
147            .write()
148            .expect("in_flight shard poisoned");
149        guard.remove(&key)
150    }
151
152    pub fn get_cancel(&self, key: InFlightKey) -> Option<CancelFlag> {
153        let guard = self
154            .shard_for(&key)
155            .read()
156            .expect("in_flight shard poisoned");
157        guard.get(&key).map(|s| s.cancel.clone())
158    }
159
160    pub fn keys(&self) -> Vec<InFlightKey> {
161        let mut keys = Vec::new();
162        for shard in &*self.shards {
163            let guard = shard.read().expect("in_flight shard poisoned");
164            keys.extend(guard.keys().copied());
165        }
166        keys
167    }
168
169    pub fn flags(&self) -> Vec<CancelFlag> {
170        let mut flags = Vec::new();
171        for shard in &*self.shards {
172            let guard = shard.read().expect("in_flight shard poisoned");
173            flags.extend(guard.values().map(|s| s.cancel.clone()));
174        }
175        flags
176    }
177
178    /// Collect pending progress snapshots for heartbeat flush.
179    ///
180    /// For each in-flight job with `generation > acked_generation` and no
181    /// in-flight snapshot already pending, snapshots `latest` into `in_flight`.
182    /// Returns `(job_id, run_lease, generation, progress_json)` tuples.
183    pub fn snapshot_pending_progress(&self) -> Vec<(i64, i64, u64, serde_json::Value)> {
184        let mut result = Vec::new();
185        for shard in &*self.shards {
186            let guard = shard.read().expect("in_flight shard poisoned");
187            for (&(job_id, run_lease), state) in guard.iter() {
188                let mut progress = state.progress.lock().expect("progress lock poisoned");
189                if progress.has_pending() && progress.in_flight.is_none() {
190                    if let Some(ref value) = progress.latest {
191                        let gen = progress.generation;
192                        let snapshot = value.clone();
193                        progress.in_flight = Some((gen, snapshot.clone()));
194                        result.push((job_id, run_lease, gen, snapshot));
195                    }
196                }
197            }
198        }
199        result
200    }
201
202    /// Acknowledge a successful progress flush for a set of jobs.
203    pub fn ack_progress(&self, acked: &[(i64, i64, u64)]) {
204        for &(job_id, run_lease, generation) in acked {
205            let key = (job_id, run_lease);
206            let guard = self
207                .shard_for(&key)
208                .read()
209                .expect("in_flight shard poisoned");
210            if let Some(state) = guard.get(&key) {
211                let mut progress = state.progress.lock().expect("progress lock poisoned");
212                if progress
213                    .in_flight
214                    .as_ref()
215                    .is_some_and(|(gen, _)| *gen == generation)
216                {
217                    progress.acked_generation = progress.acked_generation.max(generation);
218                    progress.in_flight = None;
219                }
220            }
221        }
222    }
223
224    /// Clear in-flight snapshot on failed flush (without advancing acked).
225    pub fn clear_in_flight_progress(&self, failed: &[(i64, i64, u64)]) {
226        for &(job_id, run_lease, generation) in failed {
227            let key = (job_id, run_lease);
228            let guard = self
229                .shard_for(&key)
230                .read()
231                .expect("in_flight shard poisoned");
232            if let Some(state) = guard.get(&key) {
233                let mut progress = state.progress.lock().expect("progress lock poisoned");
234                if progress
235                    .in_flight
236                    .as_ref()
237                    .is_some_and(|(gen, _)| *gen == generation)
238                {
239                    progress.in_flight = None;
240                }
241            }
242        }
243    }
244
245    fn shard_for(&self, key: &InFlightKey) -> &InFlightShard {
246        let mut hasher = DefaultHasher::new();
247        key.hash(&mut hasher);
248        let idx = (hasher.finish() as usize) % self.shards.len();
249        &self.shards[idx]
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn ack_progress_does_not_roll_back_newer_explicit_flush() {
259        let registry = InFlightRegistry::default();
260        let key = (42, 7);
261        let progress = Arc::new(std::sync::Mutex::new(ProgressState {
262            latest: Some(serde_json::json!({"percent": 90})),
263            generation: 2,
264            acked_generation: 2,
265            in_flight: Some((1, serde_json::json!({"percent": 50}))),
266        }));
267
268        registry.insert(
269            key,
270            InFlightState {
271                cancel: Arc::new(AtomicBool::new(false)),
272                progress: progress.clone(),
273            },
274        );
275
276        registry.ack_progress(&[(key.0, key.1, 1)]);
277
278        let state = progress.lock().expect("progress lock poisoned");
279        assert_eq!(state.acked_generation, 2);
280        assert!(state.in_flight.is_none());
281    }
282}