Skip to content

Commit 8daafb4

Browse files
committed
Merge branch 'rust-325-update-validation-terminal' into rust-325-worker-poll-fence-native
2 parents 74aeeab + 90965ac commit 8daafb4

7 files changed

Lines changed: 307 additions & 36 deletions

File tree

‎rust/src/runtime/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ mod operator_metrics;
8080
mod payload_cleanup;
8181
mod payload_discovery;
8282
mod payload_limits;
83+
#[cfg(debug_assertions)]
84+
#[doc(hidden)]
85+
pub mod poll_pauses;
8386
mod poll_readiness;
8487
pub mod postgres;
8588
mod previews;

‎rust/src/runtime/poll_pauses.rs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//! A debug-build test seam for workflow polls that carry a request ID. A test
2+
//! registers a stop for one request ID at one point; the next poll with that
3+
//! ID to reach the point reports its arrival and waits there until the test
4+
//! lets it go. Regressions use it to commit a duplicate's work inside a window
5+
//! between a poll's steps, in a fixed order and without sleeping. Release
6+
//! builds compile neither this module nor its call sites.
7+
use std::sync::{Mutex, PoisonError};
8+
use tokio::sync::oneshot;
9+
10+
/// Where a workflow poll with a request ID can be stopped.
11+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12+
pub enum PollPoint {
13+
/// A held single-kind poll whose probe found nothing and whose queue
14+
/// had not changed, before it waits for work.
15+
Waiting,
16+
/// After the poll's last probe and its decision to answer without a
17+
/// task, before it records that answer under the request ID.
18+
Terminal,
19+
}
20+
21+
/// A registered stop. `reached` resolves when the poll arrives; sending on
22+
/// `release`, or dropping it, lets the poll go on.
23+
pub struct PollPause {
24+
pub reached: oneshot::Receiver<()>,
25+
pub release: oneshot::Sender<()>,
26+
}
27+
28+
struct Stop {
29+
request_id: String,
30+
point: PollPoint,
31+
arrived: oneshot::Sender<()>,
32+
resume: oneshot::Receiver<()>,
33+
}
34+
35+
static STOPS: Mutex<Vec<Stop>> = Mutex::new(Vec::new());
36+
37+
/// Stop the next workflow poll with `request_id` at `point`, once. The stops
38+
/// are process-wide, so each test uses request IDs of its own.
39+
pub fn pause_workflow_poll(request_id: &str, point: PollPoint) -> PollPause {
40+
let (arrived, reached) = oneshot::channel();
41+
let (release, resume) = oneshot::channel();
42+
STOPS
43+
.lock()
44+
.unwrap_or_else(PoisonError::into_inner)
45+
.push(Stop { request_id: request_id.to_owned(), point, arrived, resume });
46+
PollPause { reached, release }
47+
}
48+
49+
/// A poll at `point`: wait there if a test registered a stop for its ID.
50+
pub(super) async fn stop_at(request_id: &str, point: PollPoint) {
51+
let stop = {
52+
let mut stops = STOPS.lock().unwrap_or_else(PoisonError::into_inner);
53+
stops
54+
.iter()
55+
.position(|stop| stop.request_id == request_id && stop.point == point)
56+
.map(|index| stops.swap_remove(index))
57+
};
58+
if let Some(stop) = stop {
59+
let _ = stop.arrived.send(());
60+
let _ = stop.resume.await;
61+
}
62+
}

‎rust/src/runtime/store.rs‎

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,12 +1474,8 @@ where
14741474
return Err(refuse(StatusCode::CONFLICT, "task_queue_mismatch"));
14751475
}
14761476
let request = self.workflow_poll_request(worker_id, queue, id, default_wait, &["workflow"]).await?;
1477-
if let Some(mut replay) = self.replay_workflow_poll(&request, worker_id, queue).await? {
1478-
if replay["task"].is_object() {
1479-
super::history_pages::compress(&mut replay["task"], encoding)?;
1480-
}
1481-
replay["server_capabilities"] = capabilities();
1482-
return Ok(replay);
1477+
if let Some(replay) = self.replay_workflow_poll(&request, worker_id, queue).await? {
1478+
return replayed_poll(replay, encoding);
14831479
}
14841480
Some(request)
14851481
}
@@ -1531,29 +1527,41 @@ where
15311527
);
15321528
}
15331529
if tokio::time::Instant::now() >= deadline {
1534-
if let Some(request) = &poll_request {
1535-
self.remember_empty_workflow_poll(request, "empty").await?;
1536-
}
1537-
return Ok(
1538-
json!({"task": null, "poll_status": "empty", "server_capabilities": capabilities()}),
1539-
);
1530+
return self.no_task_answer(&body, poll_request.as_ref(), "empty", encoding).await;
15401531
}
15411532
if self
15421533
.poll_queue_changed(&body, kind, &mut cross_kind)
15431534
.await?
15441535
&& tokio::time::Instant::now() < deadline
15451536
{
1546-
if let Some(request) = &poll_request {
1547-
self.remember_empty_workflow_poll(request, "task_queue_changed").await?;
1548-
}
1549-
return Ok(
1550-
json!({"task":null,"poll_status":"task_queue_changed","server_capabilities":capabilities()}),
1551-
);
1537+
return self.no_task_answer(&body, poll_request.as_ref(), "task_queue_changed", encoding).await;
1538+
}
1539+
#[cfg(debug_assertions)]
1540+
if let Some(request) = &poll_request {
1541+
super::poll_pauses::stop_at(&request.id, super::poll_pauses::PollPoint::Waiting).await;
15521542
}
15531543
pace.wait(wake, deadline).await;
15541544
}
15551545
}
15561546

1547+
/// A poll's answer when it leased nothing. Under a request ID, a
1548+
/// duplicate's lease committed after this poll's last probe replays
1549+
/// instead, as a retry with that ID would see it.
1550+
async fn no_task_answer(
1551+
&self,
1552+
body: &Value,
1553+
poll_request: Option<&super::workflow_poll_requests::WorkflowPollRequest>,
1554+
poll_status: &str,
1555+
encoding: Option<&'static str>,
1556+
) -> Result<Value> {
1557+
if let Some(request) = poll_request
1558+
&& let Some(lease) = self.remember_empty_workflow_poll(request, text(body, "worker_id")?, text(body, "task_queue")?, poll_status).await?
1559+
{
1560+
return replayed_poll(lease, encoding);
1561+
}
1562+
Ok(json!({"task": null, "poll_status": poll_status, "server_capabilities": capabilities()}))
1563+
}
1564+
15571565
/// Whether a held workflow or activity poll should claim again.
15581566
async fn task_poll_ready(
15591567
&self,
@@ -1659,8 +1667,7 @@ where
16591667
// replays a validation answer itself before claiming.
16601668
if let Some(request) = poll_request {
16611669
Self::bind_workflow_poll(tx, request).await?;
1662-
if let Some(answer) = self.deliverable_workflow_poll(tx, request, worker_id, queue).await?
1663-
&& answer["task"].is_object()
1670+
if let Some(answer) = self.deliverable_workflow_lease(tx, request, worker_id, queue).await?
16641671
&& answer["task"]["task_kind"] != "update_validation"
16651672
{
16661673
return Ok(Some(answer["task"].clone()));
@@ -4176,6 +4183,16 @@ pub(super) fn reject_fields(body: &Value, allowed: &[&str]) -> Result<()> {
41764183
Ok(())
41774184
}
41784185

4186+
/// A replayed answer as a workflow poll delivers it: a lease takes this
4187+
/// request's history encoding.
4188+
pub(super) fn replayed_poll(mut replay: Value, encoding: Option<&'static str>) -> Result<Value> {
4189+
if replay["task"].is_object() {
4190+
super::history_pages::compress(&mut replay["task"], encoding)?;
4191+
}
4192+
replay["server_capabilities"] = capabilities();
4193+
Ok(replay)
4194+
}
4195+
41794196
pub(crate) fn capabilities() -> Value {
41804197
let mut value = json!({"supported_workflow_task_commands": ["schedule_activity", "start_timer", "start_child_workflow", "open_condition_wait", "open_signal_wait", "record_version_marker", "record_side_effect", "complete_workflow", "fail_workflow", "continue_as_new", "complete_update", "fail_update"],
41814198
"worker_deregistration_fencing":{"schema":"durable-workflow.v2.worker-deregistration.v1","supported":true,"receipt_retention_seconds":600,"endpoint":"/worker/registrations/{workerId}/deregister"},

‎rust/src/runtime/update_validation.rs‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -938,15 +938,11 @@ where
938938

939939
/// A replayed answer as the route delivers it: a validation lease is
940940
/// rebuilt from its task, any other answer is returned as cached.
941-
fn replayed(mut answer: Value, encoding: Option<&'static str>) -> Result<Leased> {
941+
fn replayed(answer: Value, encoding: Option<&'static str>) -> Result<Leased> {
942942
if answer["task"]["task_kind"] == "update_validation" {
943943
return Ok(Leased::Validation(text(&answer["task"], "update_validation_task_id")?.to_owned()));
944944
}
945-
if answer["task"].is_object() {
946-
super::history_pages::compress(&mut answer["task"], encoding)?;
947-
}
948-
answer["server_capabilities"] = super::store::capabilities();
949-
Ok(Leased::Workflow(answer))
945+
super::store::replayed_poll(answer, encoding).map(Leased::Workflow)
950946
}
951947

952948
/// `WorkflowTaskPoller` with validation tasks requested. A request ID
@@ -955,7 +951,9 @@ where
955951
/// transaction: replay a duplicate's answer, lock the queue's cursor,
956952
/// claim the kind it names first and then the other, turn the cursor
957953
/// after a lease, and record the answer under the request ID. When
958-
/// neither kind has work, the workflow poll's answer stands.
954+
/// neither kind has work, the workflow poll's answer stands, unless a
955+
/// duplicate leased under the request ID after the last probe: that
956+
/// lease replays.
959957
pub(super) async fn poll_task_kinds(
960958
&self,
961959
body: &Value,
@@ -1013,8 +1011,10 @@ where
10131011
None => {}
10141012
}
10151013
if tokio::time::Instant::now() >= deadline {
1016-
if let Some(request) = &request {
1017-
self.remember_empty_workflow_poll(request, "empty").await?;
1014+
if let Some(request) = &request
1015+
&& let Some(lease) = self.remember_empty_workflow_poll(request, &worker_id, &task_queue, "empty").await?
1016+
{
1017+
return Self::replayed(lease, encoding);
10181018
}
10191019
return Ok(Leased::Workflow(json!({"task":null,"poll_status":"empty","server_capabilities":super::store::capabilities()})));
10201020
}
@@ -1044,9 +1044,7 @@ where
10441044
// binding row, locked first, makes it wait for that one's answer.
10451045
if let Some(request) = request {
10461046
Self::bind_workflow_poll(&mut tx, request).await?;
1047-
if let Some(answer) = self.deliverable_workflow_poll(&mut tx, request, worker_id, task_queue).await?
1048-
&& answer["task"].is_object()
1049-
{
1047+
if let Some(answer) = self.deliverable_workflow_lease(&mut tx, request, worker_id, task_queue).await? {
10501048
tx.commit().await?;
10511049
return Ok(Some(Probe::Replayed(answer)));
10521050
}

‎rust/src/runtime/workflow_poll_requests.rs‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
//! validation lease while it stays leased at the same attempt. Once a lease is
99
//! no longer deliverable, the ID polls afresh. PHP never refuses a retry as
1010
//! expired.
11+
//!
12+
//! PHP elects one pending leader per ID, and duplicates only read its answer.
13+
//! Native duplicates each probe, so every write of an answer first replays a
14+
//! deliverable lease a duplicate committed: a claim replays it instead of
15+
//! leasing again, and a poll that found nothing returns it instead of
16+
//! recording `empty` or `task_queue_changed` over it.
1117
use super::transaction::Transaction;
1218
use super::{Result, RuntimeError, store::now};
1319
use axum::http::StatusCode;
@@ -193,6 +199,20 @@ store_methods! {
193199
Ok(None)
194200
}
195201

202+
/// A deliverable lease a duplicate left under this request ID, which a
203+
/// claim or a no-task answer must replay rather than overwrite. Call it
204+
/// after `bind_workflow_poll`, whose lock orders it after any duplicate's
205+
/// committed answer.
206+
pub(super) async fn deliverable_workflow_lease(
207+
&self,
208+
tx: &mut Transaction<'_, DB>,
209+
request: &WorkflowPollRequest,
210+
worker_id: &str,
211+
queue: &str,
212+
) -> Result<Option<Value>> {
213+
Ok(self.deliverable_workflow_poll(tx, request, worker_id, queue).await?.filter(|answer| answer["task"].is_object()))
214+
}
215+
196216
/// `cachedTaskStillDeliverable` for a workflow task: leased on this
197217
/// queue to this worker at the delivered attempt, with a live lease,
198218
/// which the replay then shows.
@@ -260,12 +280,27 @@ store_methods! {
260280
})
261281
}
262282

263-
/// Remember an answer that leased nothing, in its own transaction.
264-
pub(super) async fn remember_empty_workflow_poll(&self, request: &WorkflowPollRequest, poll_status: &str) -> Result<()> {
283+
/// Remember an answer that leased nothing (`empty` or
284+
/// `task_queue_changed`), in its own transaction. A duplicate may have
285+
/// leased under this ID after this poll's last probe; its deliverable
286+
/// lease is returned for the caller to replay, and stays recorded.
287+
pub(super) async fn remember_empty_workflow_poll(
288+
&self,
289+
request: &WorkflowPollRequest,
290+
worker_id: &str,
291+
queue: &str,
292+
poll_status: &str,
293+
) -> Result<Option<Value>> {
294+
#[cfg(debug_assertions)]
295+
super::poll_pauses::stop_at(&request.id, super::poll_pauses::PollPoint::Terminal).await;
265296
let mut tx = self.begin().await?;
266297
Self::bind_workflow_poll(&mut tx, request).await?;
267-
Self::remember_workflow_poll(&mut tx, request, &json!({"task": null, "poll_status": poll_status})).await?;
268-
tx.commit().await
298+
let lease = self.deliverable_workflow_lease(&mut tx, request, worker_id, queue).await?;
299+
if lease.is_none() {
300+
Self::remember_workflow_poll(&mut tx, request, &json!({"task": null, "poll_status": poll_status})).await?;
301+
}
302+
tx.commit().await?;
303+
Ok(lease)
269304
}
270305

271306
async fn workflow_poll_row(tx: &mut Transaction<'_, DB>, key: &str) -> Result<Option<Value>> {

‎rust/tests/execution/update_validation.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,64 @@ async fn concurrent_probes_alternate_kinds_without_losing_a_turn() {
505505
}
506506
}
507507
}
508+
509+
// A duplicate can lease either kind after a multiplexed poll's last probe and
510+
// before that poll records `empty`. The held poll then returns the
511+
// duplicate's lease, and a retry whose original response was lost recovers
512+
// it. Debug builds stop the held poll between the two steps.
513+
#[cfg(debug_assertions)]
514+
mod terminal_races {
515+
use super::*;
516+
use durable_workflow_server::runtime::poll_pauses::{PollPoint, pause_workflow_poll};
517+
518+
async fn multiplexed(app: &Router, worker: &str, queue: &str, request_id: &str, timeout: u64) -> (StatusCode, Value) {
519+
request(app, "POST", "/api/worker/workflow-tasks/poll", json!({"worker_id":worker,"task_queue":queue,
520+
"timeout_seconds":timeout,"poll_request_id":request_id,"task_kinds":["workflow","update_validation"]})).await
521+
}
522+
523+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
524+
async fn a_lease_committed_before_the_empty_answer_replays_for_either_kind() {
525+
for kind in ["update_validation", "workflow"] {
526+
let database = TestDatabase::new().await;
527+
let runtime = database.open().await.unwrap();
528+
let app = router(runtime.clone());
529+
let (worker, queue, run) = ("late", format!("q-late-{kind}"), format!("late-run-{kind}"));
530+
validating_worker(&app, worker, &queue, Some(json!(["approve_me"])), true).await;
531+
waiting_run(&app, &run, &queue, worker).await;
532+
let id = format!("terminal-race-multiplexed-{kind}");
533+
let mut terminal = pause_workflow_poll(&id, PollPoint::Terminal);
534+
535+
// The held poll finds neither kind and stops before answering.
536+
let held = {
537+
let (app, queue, id) = (app.clone(), queue.clone(), id.clone());
538+
tokio::spawn(async move { multiplexed(&app, worker, &queue, &id, 0).await })
539+
};
540+
(&mut terminal.reached).await.unwrap();
541+
// Work of one kind arrives, and a duplicate waits for it and
542+
// leases it; its response is lost.
543+
let pending = if kind == "update_validation" {
544+
Some(send_update(&app, &run, "approve_me", update_body(7, Some("late-update"))))
545+
} else {
546+
start_on(&app, &format!("late-ready-{kind}"), &queue).await;
547+
None
548+
};
549+
let (status, leased) = multiplexed(&app, worker, &queue, &id, 10).await;
550+
assert_eq!((status, leased["poll_status"].clone(), delivery(&leased).0), (StatusCode::OK, json!("leased"), json!(kind)), "{leased}");
551+
terminal.release.send(()).unwrap();
552+
553+
let (status, answered) = held.await.unwrap();
554+
assert_eq!(status, StatusCode::OK, "{answered}");
555+
assert_eq!((answered["poll_status"].clone(), delivery(&answered)), (json!("leased"), delivery(&leased)), "{kind}: {answered}");
556+
let (status, retried) = multiplexed(&app, worker, &queue, &id, 0).await;
557+
assert_eq!((status, delivery(&retried)), (StatusCode::OK, delivery(&leased)), "{kind}: {retried}");
558+
if let Some(pending) = pending {
559+
let task = delivery(&leased).1;
560+
let approved = complete(&app, task.as_str().unwrap(), "approve", json!({"lease_owner":worker,"update_validation_attempt":1})).await;
561+
assert_eq!(approved.0, StatusCode::OK, "{}", approved.1);
562+
assert_eq!(pending.await.unwrap().0, StatusCode::ACCEPTED);
563+
}
564+
runtime.close().await;
565+
database.remove().await;
566+
}
567+
}
568+
}

0 commit comments

Comments
 (0)