1use 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 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 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 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}