durable_workflow/
sticky_worker.rs

1//! Managed-worker caching is separate from the public full-history client API.
2
3use super::*;
4use sticky_workflow_cache::{complete_history, CacheKey, ResumeCursor, StickyWorkflowCache};
5
6#[derive(Clone, Debug)]
7pub(super) struct StickySnapshot {
8    key: CacheKey,
9    history: Vec<Value>,
10    resume: Option<ResumeCursor>,
11}
12
13pub(super) struct ClearCacheOnDrop(pub Option<Arc<Mutex<StickyWorkflowCache>>>);
14impl Drop for ClearCacheOnDrop {
15    fn drop(&mut self) {
16        if let Some(cache) = &self.0 {
17            if let Ok(mut cache) = cache.lock() {
18                cache.clear();
19            }
20        }
21    }
22}
23
24fn invalid(message: &str) -> Error {
25    Error::Codec(format!("sticky_history_invalid: {message}"))
26}
27
28impl Worker {
29    /// Explicitly enable a bounded durable-history cache. Disabled by default.
30    ///
31    /// The encoded-byte limit excludes transient JSON decoding, replay, keys and
32    /// cursor metadata. This never retains live workflow instances or session resources.
33    pub fn sticky_cache(mut self, options: StickyCacheOptions) -> Result<Self> {
34        if options.ttl.subsec_nanos() != 0 {
35            return Err(invalid("sticky cache TTL must use whole seconds"));
36        }
37        let cache = StickyWorkflowCache::new(options.capacity, options.max_bytes, options.ttl)
38            .map_err(invalid)?;
39        self.client.sticky_cache = cache.enabled().then(|| Arc::new(Mutex::new(cache)));
40        self.sticky_registration_confirmed = Arc::new(AtomicBool::new(false));
41        Ok(self)
42    }
43
44    /// Select the deployment build identity used for registration, routing and cache keys.
45    ///
46    /// An omitted build ID uses the SDK identity for cache claims, preserving the
47    /// Server's unversioned registration default. An explicit ID must be 1..=255 bytes.
48    pub fn build_id(mut self, build_id: impl Into<String>) -> Self {
49        self.client.worker_build_id = Some(build_id.into().trim().to_owned());
50        let worker_id = self.worker_id.clone();
51        self.worker_id(worker_id)
52    }
53
54    pub fn sticky_cache_metrics(&self) -> Result<StickyCacheMetrics> {
55        self.client
56            .sticky_cache
57            .as_ref()
58            .map_or(Ok(StickyCacheMetrics::default()), |cache| {
59                Ok(cache
60                    .lock()
61                    .map_err(|_| Error::WorkflowStatePoisoned)?
62                    .metrics(Instant::now()))
63            })
64    }
65
66    pub(super) async fn poll_workflow_with_sticky_cache(
67        &self,
68        poll_request_id: &str,
69    ) -> Result<(PollWorkflowTaskResponse, Option<StickySnapshot>)> {
70        if self.client.sticky_cache.is_none() {
71            return self
72                .client
73                .poll_workflow_task_response_with_request_id(
74                    &self.worker_id,
75                    &self.task_queue,
76                    self.poll_timeout,
77                    poll_request_id,
78                    0,
79                )
80                .await
81                .map(|response| (response, None));
82        }
83        let body = json!({"worker_id":self.worker_id,"task_queue":self.task_queue,
84            "poll_request_id":poll_request_id,"timeout_seconds":long_poll_timeout_seconds(self.poll_timeout),
85            "history_page_size":WORKFLOW_HISTORY_PAGE_SIZE,"build_id":self.client.worker_build_id});
86        let wire: Value = self
87            .client
88            .poll_request_json(
89                "/worker/workflow-tasks/poll",
90                RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
91                &body,
92                self.poll_timeout + Duration::from_secs(5),
93                0,
94            )
95            .await?;
96        let mut response: PollWorkflowTaskResponse = serde_json::from_value(wire.clone())?;
97        let snapshot = if let Some(task) = response.task.as_mut() {
98            Some(
99                self.client
100                    .load_sticky_history(task, &wire["task"], &self.worker_id)
101                    .await?,
102            )
103        } else {
104            None
105        };
106        Ok((response, snapshot))
107    }
108
109    pub(super) fn sticky_claim(
110        &self,
111        snapshot: Option<&StickySnapshot>,
112        commands: &[Value],
113    ) -> Result<Option<Value>> {
114        let Some(snapshot) = snapshot else {
115            return Ok(None);
116        };
117        let Some(cache) = &self.client.sticky_cache else {
118            return Ok(None);
119        };
120        let mut cache = cache.lock().map_err(|_| Error::WorkflowStatePoisoned)?;
121        if commands.iter().any(|command| {
122            command["type"].as_str().is_some_and(|kind| {
123                matches!(
124                    kind,
125                    "complete_workflow"
126                        | "fail_workflow"
127                        | "continue_as_new"
128                        | "acknowledge_cancellation"
129                )
130            })
131        }) {
132            cache.discard(&snapshot.key);
133            return Ok(None);
134        }
135        if !cache.remember(
136            snapshot.key.clone(),
137            &snapshot.history,
138            snapshot.resume.clone(),
139            Instant::now(),
140        ) {
141            return Ok(None);
142        }
143        let metrics = cache.metrics(Instant::now());
144        Ok(Some(
145            json!({"worker_id":self.worker_id,"workflow_id":snapshot.key.workflow_id,
146            "run_id":snapshot.key.run_id,"build_id":snapshot.key.build_id,"ttl_seconds":cache.ttl_seconds(),
147            "metrics":{"hit":metrics.hit,"miss":metrics.miss,"eviction":metrics.eviction,
148                "forced_cold_replay":metrics.forced_cold_replay}}),
149        ))
150    }
151
152    pub(super) fn discard_sticky_snapshot(&self, snapshot: Option<&StickySnapshot>) -> Result<()> {
153        if let (Some(cache), Some(snapshot)) = (&self.client.sticky_cache, snapshot) {
154            cache
155                .lock()
156                .map_err(|_| Error::WorkflowStatePoisoned)?
157                .discard(&snapshot.key);
158        }
159        Ok(())
160    }
161}
162
163impl Client {
164    pub(super) fn reset_sticky_cache(&mut self) {
165        self.sticky_cache = self.sticky_cache.as_ref().map(|cache| {
166            // Only immutable, validated options are copied from a poisoned cache.
167            let empty = cache
168                .lock()
169                .unwrap_or_else(|poison| poison.into_inner())
170                .empty();
171            Arc::new(Mutex::new(empty))
172        });
173    }
174
175    pub(super) fn clear_sticky_cache(&self) -> Result<()> {
176        if let Some(cache) = &self.sticky_cache {
177            cache
178                .lock()
179                .map_err(|_| Error::WorkflowStatePoisoned)?
180                .clear();
181        }
182        Ok(())
183    }
184
185    pub(super) async fn confirm_sticky_registration(
186        &self,
187        response: &Value,
188        worker_id: &str,
189        queue: &str,
190    ) -> Result<()> {
191        let accepted = response["registered"] == true
192            && response["worker_id"].as_str() == Some(worker_id)
193            && response["namespace"].as_str() == Some(self.namespace.as_str())
194            && response["task_queue"].as_str() == Some(queue)
195            && response["build_id"].as_str() == self.worker_build_id.as_deref()
196            && response["protocol_version"]
197                .as_str()
198                .and_then(|version| version.strip_prefix("1."))
199                .and_then(|minor| minor.parse::<u64>().ok())
200                .is_some_and(|minor| minor >= 18)
201            && response["capabilities"]
202                .as_array()
203                .is_some_and(|caps| caps.iter().any(|cap| cap == "sticky_execution"))
204            && response["capability_manifest"]["sticky_execution"]["supported"] == true
205            && response["server_capabilities"]["sticky_execution"]["supported"] == true;
206        if accepted {
207            return Ok(());
208        }
209        self.clear_sticky_cache()?;
210        let error = Error::WorkerLoop("sticky_registration_unconfirmed: Server must acknowledge this worker, namespace, queue, build and sticky capability".into());
211        if response["registered"] == true && response["worker_id"].as_str() == Some(worker_id) {
212            if let Err(deregistration) = self.deregister_worker_registration(worker_id).await {
213                return Err(Error::WorkerShutdown {
214                    primary: Box::new(error),
215                    deregistration: Box::new(deregistration),
216                });
217            }
218        }
219        Err(error)
220    }
221
222    pub(super) async fn load_sticky_history(
223        &self,
224        task: &mut WorkflowTask,
225        wire: &Value,
226        worker_id: &str,
227    ) -> Result<StickySnapshot> {
228        if task.lease_owner.as_deref() != Some(worker_id)
229            || wire["workflow_task_attempt"].as_u64() != Some(task.workflow_task_attempt)
230        {
231            return Err(invalid("poll changed the worker's actual owner or attempt"));
232        }
233        let key = CacheKey {
234            workflow_id: task
235                .workflow_id
236                .clone()
237                .filter(|id| !id.is_empty())
238                .ok_or_else(|| invalid("workflow ID is missing"))?,
239            run_id: task
240                .run_id
241                .clone()
242                .filter(|id| !id.is_empty())
243                .ok_or_else(|| invalid("run ID is missing"))?,
244            build_id: self
245                .worker_build_id
246                .clone()
247                .unwrap_or_else(|| SDK_VERSION.into()),
248        };
249        let inline: Vec<Value> =
250            serde_json::from_value(serde_json::to_value(&task.history_events)?)?;
251        let mode = wire["sticky_replay_mode"].as_str();
252        let cache = self
253            .sticky_cache
254            .as_ref()
255            .ok_or_else(|| invalid("cache profile is disabled"))?;
256        let cached = if mode == Some("sticky_hit_expected") {
257            cache
258                .lock()
259                .map_err(|_| Error::WorkflowStatePoisoned)?
260                .lookup(&key, Instant::now())
261        } else {
262            None
263        };
264        let last_sequence = wire
265            .get("last_history_sequence")
266            .or_else(|| wire.get("total_history_events"))
267            .and_then(Value::as_u64);
268        if let Some((prefix, resume)) = cached {
269            let overlap = inline.len().min(prefix.len());
270            if complete_history(&inline)
271                && inline[..overlap] == prefix[..overlap]
272                && last_sequence.is_some_and(|last| last >= prefix.len() as u64)
273            {
274                let warmed = if let Some(cursor) = resume
275                    .filter(|cursor| cursor.offset >= inline.len() && inline.len() < prefix.len())
276                {
277                    let tail = match self
278                        .fetch_sticky_pages(task, Vec::new(), Some(cursor.token), cursor.offset)
279                        .await
280                    {
281                        Ok(value) => Some(value),
282                        Err(Error::Codec(_)) => None,
283                        Err(Error::Http { status, body })
284                            if status == reqwest::StatusCode::BAD_REQUEST
285                                && serde_json::from_str::<Value>(&body).ok().is_some_and(
286                                    |value| value["reason"] == "invalid_page_token",
287                                ) =>
288                        {
289                            None
290                        }
291                        Err(error) => return Err(error),
292                    };
293                    tail.and_then(|(tail, resume)| {
294                        let overlap = prefix.len() - cursor.offset;
295                        if tail.get(..overlap) != Some(&prefix[cursor.offset..]) {
296                            return None;
297                        }
298                        let mut history = prefix[..cursor.offset].to_vec();
299                        history.extend(tail);
300                        Some((history, resume))
301                    })
302                } else {
303                    let next = task.next_history_page_token.clone();
304                    let (history, resume) = self
305                        .fetch_sticky_pages(task, inline.clone(), next, 0)
306                        .await?;
307                    (history.get(..prefix.len()) == Some(prefix.as_slice()))
308                        .then_some((history, resume))
309                };
310                if let Some((history, resume)) = warmed.filter(|(history, _)| {
311                    complete_history(history)
312                        && last_sequence.is_some_and(|last| history.len() as u64 >= last)
313                }) {
314                    cache
315                        .lock()
316                        .map_err(|_| Error::WorkflowStatePoisoned)?
317                        .record_replay(true, false);
318                    task.history_events = serde_json::from_value(Value::Array(history.clone()))?;
319                    task.next_history_page_token = None;
320                    return Ok(StickySnapshot {
321                        key,
322                        history,
323                        resume,
324                    });
325                }
326            }
327            cache
328                .lock()
329                .map_err(|_| Error::WorkflowStatePoisoned)?
330                .discard(&key);
331        }
332        cache
333            .lock()
334            .map_err(|_| Error::WorkflowStatePoisoned)?
335            .record_replay(
336                false,
337                matches!(mode, Some("sticky_hit_expected" | "forced_cold_replay")),
338            );
339        let (seed, token) = if !complete_history(&inline)
340            && matches!(mode, Some("sticky_hit_expected" | "forced_cold_replay"))
341        {
342            (Vec::new(), Some("MA==".into()))
343        } else {
344            (inline, task.next_history_page_token.clone())
345        };
346        let (history, resume) = self.fetch_sticky_pages(task, seed, token, 0).await?;
347        if !complete_history(&history)
348            || last_sequence.is_some_and(|last| (history.len() as u64) < last)
349        {
350            return Err(invalid("cold replay lacks complete canonical history"));
351        }
352        task.history_events = serde_json::from_value(Value::Array(history.clone()))?;
353        task.next_history_page_token = None;
354        Ok(StickySnapshot {
355            key,
356            history,
357            resume,
358        })
359    }
360
361    async fn fetch_sticky_pages(
362        &self,
363        task: &mut WorkflowTask,
364        mut history: Vec<Value>,
365        mut token: Option<String>,
366        offset: usize,
367    ) -> Result<(Vec<Value>, Option<ResumeCursor>)> {
368        let mut seen = BTreeSet::new();
369        let mut resume = None;
370        while let Some(current) = token.take() {
371            if current.is_empty() || !seen.insert(current.clone()) {
372                return Err(invalid("history cursor did not advance"));
373            }
374            let body = json!({"lease_owner":task.lease_owner,"workflow_task_attempt":task.workflow_task_attempt,
375                "next_history_page_token":current,"history_page_size":WORKFLOW_HISTORY_PAGE_SIZE});
376            let page: Value = self
377                .request_json(
378                    reqwest::Method::POST,
379                    &format!(
380                        "/worker/workflow-tasks/{}/history",
381                        percent_encode_path_segment(&task.task_id)
382                    ),
383                    RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
384                    Some(&body),
385                )
386                .await?;
387            if page["task_id"].as_str() != Some(task.task_id.as_str())
388                || page["workflow_task_attempt"].as_u64() != Some(task.workflow_task_attempt)
389            {
390                return Err(invalid("history page changed the current task or attempt"));
391            }
392            let events = page["history_events"]
393                .as_array()
394                .ok_or_else(|| invalid("history page has no event array"))?;
395            if events.len() > WORKFLOW_HISTORY_PAGE_SIZE
396                || events.iter().enumerate().any(|(index, event)| {
397                    !event.is_object()
398                        || event["sequence"].as_u64()
399                            != Some((offset + history.len() + index + 1) as u64)
400                })
401            {
402                return Err(invalid("history page is oversized or not contiguous"));
403            }
404            token = match page.get("next_history_page_token") {
405                Some(Value::Null) => None,
406                Some(Value::String(next)) if !next.is_empty() && !events.is_empty() => {
407                    Some(next.clone())
408                }
409                _ => return Err(invalid("history page has an invalid cursor or no progress")),
410            };
411            resume = Some(ResumeCursor {
412                token: current,
413                offset: offset + history.len(),
414            });
415            history.extend_from_slice(events);
416            if token.is_none()
417                && page["total_history_events"]
418                    .as_u64()
419                    .is_some_and(|total| ((offset + history.len()) as u64) < total)
420            {
421                return Err(invalid("final history page omitted advertised events"));
422            }
423            if let Some(total) = page["total_history_events"].as_u64() {
424                task.total_history_events = Some(total);
425            }
426        }
427        Ok((history, resume))
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use tokio::io::{AsyncReadExt, AsyncWriteExt};
435
436    type Request = (String, String, Value);
437    async fn server(replies: Vec<(u16, Value)>) -> (String, tokio::task::JoinHandle<Vec<Request>>) {
438        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
439        let url = format!("http://{}", listener.local_addr().unwrap());
440        let handle = tokio::spawn(async move {
441            let mut captured = Vec::new();
442            for (status, body) in replies {
443                let (mut stream, _) =
444                    tokio::time::timeout(Duration::from_secs(5), listener.accept())
445                        .await
446                        .unwrap()
447                        .unwrap();
448                let mut bytes = Vec::new();
449                let (header_end, length) = loop {
450                    let mut buffer = [0; 4096];
451                    let count = stream.read(&mut buffer).await.unwrap();
452                    assert!(count > 0);
453                    bytes.extend_from_slice(&buffer[..count]);
454                    if let Some(offset) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
455                        let header = String::from_utf8_lossy(&bytes[..offset]).to_ascii_lowercase();
456                        let length = header
457                            .lines()
458                            .find_map(|line| line.strip_prefix("content-length: "))
459                            .and_then(|value| value.parse::<usize>().ok())
460                            .unwrap_or(0);
461                        break (offset + 4, length);
462                    }
463                };
464                while bytes.len() < header_end + length {
465                    let mut buffer = [0; 4096];
466                    let count = stream.read(&mut buffer).await.unwrap();
467                    assert!(count > 0);
468                    bytes.extend_from_slice(&buffer[..count]);
469                }
470                let headers = String::from_utf8_lossy(&bytes[..header_end]);
471                let path = headers
472                    .lines()
473                    .next()
474                    .unwrap()
475                    .split_whitespace()
476                    .nth(1)
477                    .unwrap()
478                    .to_owned();
479                let protocol = headers
480                    .lines()
481                    .find_map(|line| {
482                        line.to_ascii_lowercase()
483                            .strip_prefix("x-durable-workflow-protocol-version: ")
484                            .map(str::to_owned)
485                    })
486                    .unwrap_or_default();
487                let request = if length == 0 {
488                    Value::Null
489                } else {
490                    serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap()
491                };
492                captured.push((path, protocol, request));
493                let encoded = body.to_string();
494                let response = format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{encoded}", encoded.len());
495                stream.write_all(response.as_bytes()).await.unwrap();
496            }
497            captured
498        });
499        (url, handle)
500    }
501
502    fn history(count: usize) -> Vec<Value> {
503        (1..=count).map(|sequence| json!({"event_type":if sequence == 1 {"WorkflowStarted"} else {"SideEffectRecorded"},
504            "sequence":sequence,"payload":{"identity":sequence}})).collect()
505    }
506    fn wire(inline: Vec<Value>, total: usize, mode: &str) -> Value {
507        json!({"task_id":"task-now","workflow_id":"workflow","run_id":"run","workflow_type":"Test",
508            "workflow_task_attempt":7,"lease_owner":"worker","payload_codec":"avro",
509            "history_events":inline,"total_history_events":total,"next_history_page_token":"inline:500",
510            "sticky_replay_mode":mode})
511    }
512    fn page(events: Vec<Value>, next: Option<&str>) -> Value {
513        let total = if next.is_some() {
514            1002
515        } else {
516            events
517                .last()
518                .and_then(|event| event["sequence"].as_u64())
519                .unwrap_or(1002)
520        };
521        json!({"task_id":"task-now","workflow_task_attempt":7,"history_events":events,
522            "total_history_events":total,"next_history_page_token":next})
523    }
524    fn worker(url: String, bytes: usize) -> Worker {
525        Worker::new(Client::new(url).unwrap(), "queue")
526            .worker_id("worker")
527            .sticky_cache(StickyCacheOptions::new(2).max_history_bytes(bytes))
528            .unwrap()
529    }
530    fn key() -> CacheKey {
531        CacheKey {
532            workflow_id: "workflow".into(),
533            run_id: "run".into(),
534            build_id: SDK_VERSION.into(),
535        }
536    }
537    fn remember(worker: &Worker, prefix: Vec<Value>) {
538        assert!(worker
539            .client
540            .sticky_cache
541            .as_ref()
542            .unwrap()
543            .lock()
544            .unwrap()
545            .remember(
546                key(),
547                &prefix,
548                Some(ResumeCursor {
549                    token: "resume:1000".into(),
550                    offset: 1000
551                }),
552                Instant::now()
553            ));
554    }
555
556    #[tokio::test]
557    async fn warm_reuse_fetches_only_tail_with_the_current_lease() {
558        let authoritative = history(1002);
559        let (url, requests) = server(vec![(200, page(authoritative[1000..].to_vec(), None))]).await;
560        let worker = worker(url, 1_000_000);
561        remember(&worker, authoritative[..1001].to_vec());
562        let wire = wire(authoritative[..500].to_vec(), 1002, "sticky_hit_expected");
563        let mut task = serde_json::from_value(wire.clone()).unwrap();
564        let snapshot = worker
565            .client
566            .load_sticky_history(&mut task, &wire, "worker")
567            .await
568            .unwrap();
569        assert_eq!(snapshot.history, authoritative);
570        assert_eq!(snapshot.resume.unwrap().offset, 1000);
571        assert_eq!(task.history_events.len(), 1002);
572        assert_eq!(worker.sticky_cache_metrics().unwrap().hit, 1);
573        let requests = requests.await.unwrap();
574        assert_eq!(requests.len(), 1);
575        assert_eq!(requests[0].0, "/api/worker/workflow-tasks/task-now/history");
576        assert_eq!(requests[0].2["workflow_task_attempt"], 7);
577        assert_eq!(requests[0].2["lease_owner"], "worker");
578        assert_eq!(requests[0].2["next_history_page_token"], "resume:1000");
579    }
580
581    #[tokio::test]
582    async fn stale_cursor_falls_back_to_full_current_claim_history() {
583        let authoritative = history(1002);
584        let (url, requests) = server(vec![
585            (400, json!({"reason":"invalid_page_token"})),
586            (
587                200,
588                page(authoritative[500..1000].to_vec(), Some("cold:1000")),
589            ),
590            (200, page(authoritative[1000..].to_vec(), None)),
591        ])
592        .await;
593        let worker = worker(url, 1_000_000);
594        remember(&worker, authoritative[..1001].to_vec());
595        let wire = wire(authoritative[..500].to_vec(), 1002, "sticky_hit_expected");
596        let mut task = serde_json::from_value(wire.clone()).unwrap();
597        assert_eq!(
598            worker
599                .client
600                .load_sticky_history(&mut task, &wire, "worker")
601                .await
602                .unwrap()
603                .history,
604            authoritative
605        );
606        let metrics = worker.sticky_cache_metrics().unwrap();
607        assert_eq!(metrics.hit, 0);
608        assert_eq!(metrics.miss, 1);
609        assert_eq!(metrics.forced_cold_replay, 1);
610        let requests = requests.await.unwrap();
611        assert_eq!(
612            requests
613                .iter()
614                .map(|request| request.2["next_history_page_token"].as_str().unwrap())
615                .collect::<Vec<_>>(),
616            vec!["resume:1000", "inline:500", "cold:1000"]
617        );
618    }
619
620    #[tokio::test]
621    async fn incomplete_inline_hint_forces_canonical_start_cursor() {
622        let authoritative = history(7);
623        let (url, requests) = server(vec![(200, page(authoritative.clone(), None))]).await;
624        let worker = worker(url, 1_000_000);
625        let wire = wire(authoritative[5..].to_vec(), 7, "forced_cold_replay");
626        let mut task = serde_json::from_value(wire.clone()).unwrap();
627        assert_eq!(
628            worker
629                .client
630                .load_sticky_history(&mut task, &wire, "worker")
631                .await
632                .unwrap()
633                .history,
634            authoritative
635        );
636        assert_eq!(
637            requests.await.unwrap()[0].2["next_history_page_token"],
638            "MA=="
639        );
640    }
641
642    #[tokio::test]
643    async fn lost_lease_propagates_without_a_fallback_read_or_hit() {
644        let authoritative = history(1002);
645        let (url, requests) = server(vec![(409, json!({"reason":"lease_expired"}))]).await;
646        let worker = worker(url, 1_000_000);
647        remember(&worker, authoritative[..1001].to_vec());
648        let wire = wire(authoritative[..500].to_vec(), 1002, "sticky_hit_expected");
649        let mut task = serde_json::from_value(wire.clone()).unwrap();
650        assert!(matches!(
651            worker
652                .client
653                .load_sticky_history(&mut task, &wire, "worker")
654                .await,
655            Err(Error::Http {
656                status: reqwest::StatusCode::CONFLICT,
657                ..
658            })
659        ));
660        assert_eq!(requests.await.unwrap().len(), 1);
661        assert_eq!(worker.sticky_cache_metrics().unwrap().hit, 0);
662    }
663
664    #[tokio::test]
665    async fn wrong_history_attempt_is_never_replayed() {
666        let authoritative = history(1002);
667        let mut wrong = page(authoritative[500..1000].to_vec(), None);
668        wrong["workflow_task_attempt"] = json!(8);
669        let (url, requests) = server(vec![(200, wrong)]).await;
670        let worker = worker(url, 1_000_000);
671        let wire = wire(authoritative[..500].to_vec(), 1002, "cold_replay");
672        let mut task = serde_json::from_value(wire.clone()).unwrap();
673        assert!(matches!(
674            worker
675                .client
676                .load_sticky_history(&mut task, &wire, "worker")
677                .await,
678            Err(Error::Codec(_))
679        ));
680        assert_eq!(requests.await.unwrap().len(), 1);
681    }
682
683    #[tokio::test]
684    async fn successful_final_page_cannot_omit_advertised_events() {
685        let authoritative = history(1002);
686        let mut incomplete = page(authoritative[500..1000].to_vec(), None);
687        incomplete["total_history_events"] = json!(1002);
688        let (url, requests) = server(vec![(200, incomplete)]).await;
689        let worker = worker(url, 1_000_000);
690        let wire = wire(authoritative[..500].to_vec(), 1002, "cold_replay");
691        let mut task = serde_json::from_value(wire.clone()).unwrap();
692        assert!(
693            matches!(worker.client.load_sticky_history(&mut task, &wire, "worker").await,
694            Err(Error::Codec(message)) if message.contains("omitted advertised events"))
695        );
696        assert_eq!(requests.await.unwrap().len(), 1);
697    }
698
699    #[tokio::test]
700    async fn completion_claim_uses_affinity_protocol_with_older_command_minimum() {
701        let (url, requests) = server(vec![(200, json!({})), (200, json!({"recorded":true}))]).await;
702        let worker = worker(url, 1_000_000);
703        let snapshot = StickySnapshot {
704            key: key(),
705            history: history(2),
706            resume: None,
707        };
708        let entries = encode_typed_envelope(
709            &AvroValue::from_serialize(&json!({"status":"waiting"})).unwrap(),
710            DEFAULT_CODEC,
711        )
712        .unwrap();
713        let commands = vec![json!({"type":"upsert_memo","entries":entries})];
714        let claim = worker.sticky_claim(Some(&snapshot), &commands).unwrap();
715        worker
716            .client
717            .complete_workflow_task_with_message_streams(
718                "task-now",
719                "worker",
720                7,
721                commands,
722                Vec::new(),
723                Vec::new(),
724                claim,
725            )
726            .await
727            .unwrap();
728        let requests = requests.await.unwrap();
729        assert_eq!(requests[0].0, "/api/cluster/info");
730        assert_eq!(requests[1].1, "1.18");
731        assert_eq!(requests[1].2["sticky_cache"]["run_id"], "run");
732        assert_eq!(requests[1].2["workflow_task_attempt"], 7);
733    }
734
735    #[tokio::test]
736    async fn cooperative_profile_keeps_protocol_120_on_cached_page_reads() {
737        let authoritative = history(1002);
738        let (url, requests) = server(vec![(200, page(authoritative[1000..].to_vec(), None))]).await;
739        let worker = worker(url, 1_000_000).cooperative_cancellation(true);
740        remember(&worker, authoritative[..1001].to_vec());
741        let wire = wire(authoritative[..500].to_vec(), 1002, "sticky_hit_expected");
742        let mut task = serde_json::from_value(wire.clone()).unwrap();
743        worker
744            .client
745            .load_sticky_history(&mut task, &wire, "worker")
746            .await
747            .unwrap();
748        assert_eq!(requests.await.unwrap()[0].1, "1.20");
749    }
750
751    #[tokio::test]
752    async fn registration_refusal_deregisters_and_cannot_poll() {
753        let response = json!({"registered":true,"worker_id":"worker","namespace":"default","task_queue":"wrong",
754            "protocol_version":"1.19","build_id":null,"capabilities":["sticky_execution"],
755            "capability_manifest":{"sticky_execution":{"supported":true}},
756            "server_capabilities":{"sticky_execution":{"supported":true}}});
757        let (url, requests) = server(vec![
758            (200, response),
759            (
760                200,
761                json!({"worker_id":"worker","outcome":"removed","recovered_workflow_task_count":0}),
762            ),
763        ])
764        .await;
765        let worker = worker(url, 1_000_000);
766        assert!(
767            matches!(worker.register().await, Err(Error::WorkerLoop(message)) if message.contains("sticky_registration_unconfirmed"))
768        );
769        assert!(
770            matches!(worker.run_once().await, Err(Error::WorkerLoop(message)) if message.contains("sticky_registration_unconfirmed"))
771        );
772        let requests = requests.await.unwrap();
773        assert_eq!(requests.len(), 2);
774        assert_eq!(
775            requests[0].2["capability_manifest"]["sticky_execution"]["supported"],
776            true
777        );
778        assert_eq!(requests[1].0, "/api/worker/registrations/worker");
779    }
780
781    #[test]
782    fn claim_admission_bounds_terminal_cleanup_and_shutdown() {
783        let worker = worker("http://127.0.0.1:1".into(), 1_000_000);
784        let snapshot = StickySnapshot {
785            key: key(),
786            history: history(2),
787            resume: None,
788        };
789        let claim = worker
790            .sticky_claim(Some(&snapshot), &[json!({"type":"start_timer"})])
791            .unwrap()
792            .unwrap();
793        assert_eq!(claim["build_id"], SDK_VERSION);
794        assert_eq!(claim["worker_id"], "worker");
795        assert_eq!(worker.sticky_cache_metrics().unwrap().entries, 1);
796        assert!(worker
797            .sticky_claim(
798                Some(&snapshot),
799                &[json!({"type":"acknowledge_cancellation"})]
800            )
801            .unwrap()
802            .is_none());
803        assert_eq!(worker.sticky_cache_metrics().unwrap().entries, 0);
804        worker
805            .sticky_claim(Some(&snapshot), &[json!({"type":"start_timer"})])
806            .unwrap();
807        drop(ClearCacheOnDrop(worker.client.sticky_cache.clone()));
808        assert_eq!(worker.sticky_cache_metrics().unwrap().history_bytes, 0);
809        let tiny = self::worker("http://127.0.0.1:1".into(), 1);
810        assert!(tiny
811            .sticky_claim(Some(&snapshot), &[json!({"type":"start_timer"})])
812            .unwrap()
813            .is_none());
814        assert_eq!(tiny.sticky_cache_metrics().unwrap().entries, 0);
815    }
816}