Skip to main content

aion_client/
start.rs

1//! The start surface of the caller SDK: options, outcome, the SDK-boundary
2//! idempotency fingerprint, and `Client::start` itself.
3//!
4//! Split out of `ops.rs` (which the rest of the operations still share) when the
5//! display-name work (#211) pushed that file past the house 500-code-line limit.
6//! The seam is cohesive rather than arbitrary: everything here answers one
7//! question — what a start request is, what comes back from it, and when two
8//! starts are the same act.
9
10use aion_core::Payload;
11use aion_proto::{ProtoPayload, ProtoStartWorkflowRequest};
12use serde::Serialize;
13
14use crate::client::Client;
15use crate::error::ClientError;
16use crate::handle::WorkflowHandle;
17use crate::ops::{decode_required_run_id, decode_required_workflow_id, operation_namespace};
18use crate::payload::to_payload;
19
20/// Options accepted by [`Client::start`].
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct StartOptions {
23    /// Namespace override for this start request.
24    pub namespace: Option<String>,
25    /// Caller-supplied idempotency key for safe local retry replay.
26    ///
27    /// The current AW protobuf has not added an idempotency field yet, so this is
28    /// enforced at the SDK boundary without inventing a client-owned wire field.
29    /// Reusing a key for a different start request returns
30    /// [`ClientError::AlreadyExists`].
31    pub idempotency_key: Option<String>,
32    /// R-4 steered-start routing key. When set, the cluster steers this start to
33    /// `shard_for(routing_key)`'s owner (forwarding there when the dialed node is
34    /// not the owner). `None` keeps the default unsteered placement.
35    pub routing_key: Option<String>,
36    /// Optional default task queue for this workflow's activities. When set, the
37    /// server records it durably on the start (the namespace × `task_queue`
38    /// targeting story); `None` keeps the namespace's default queue.
39    pub task_queue: Option<String>,
40    /// Optional operator-facing display name for the workflow this start
41    /// creates (#211). When set, the server records it durably on the start as
42    /// the `aion.display_name` search attribute; `None` starts it unnamed (it
43    /// shows its bare UUID).
44    ///
45    /// A LABEL over the UUID identity, never an address: no SDK call resolves a
46    /// workflow by name. It is deliberately NOT part of the idempotency
47    /// fingerprint — see [`StartOutcome::display_name_not_applied`] for what
48    /// happens when a reused key carries a different name.
49    ///
50    /// You name the run you are starting, but the label READS BACK per
51    /// workflow: the recorded attribute carries no run id, so every reader
52    /// folds it over the whole history, last write wins. A continue-as-new
53    /// successor of this run inherits the name, and describing any run of the
54    /// workflow returns whatever name was recorded most recently.
55    pub display_name: Option<String>,
56}
57
58/// What [`Client::start`] returns: the started (or idempotently replayed) run,
59/// plus the note that a requested display name was NOT applied.
60///
61/// The note exists because `display_name` is deliberately outside
62/// [`StartFingerprint`]: the fingerprint answers "is this the same act?", and a
63/// label carries no identity, so two starts differing only in name are the same
64/// act and dedupe to one run. That leaves a second, different name with nowhere
65/// to go — and dropping it silently would hand the caller a run wearing a name
66/// they did not ask for while saying nothing. So the drop is REPORTED here
67/// instead. The replayed run keeps its EXISTING name; nothing is renamed behind
68/// the caller's back.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct StartOutcome {
71    /// Handle to the started run, or to the existing run an idempotent replay
72    /// matched.
73    pub handle: WorkflowHandle,
74    /// Present ONLY when this call REQUESTED a display name, an idempotency-key
75    /// replay matched an existing run, and that run does not already wear the
76    /// requested name — whether it wears a different one or none at all.
77    ///
78    /// `None` on every other path: a first start (which applies the name it
79    /// asked for), a replay that requested the same name, and — the rule this
80    /// field's absence states — any call that requested NO name. A caller who
81    /// asked for nothing had nothing dropped, so there is nothing to report,
82    /// even when the standing run wears a name of its own.
83    pub display_name_not_applied: Option<DisplayNameNotApplied>,
84}
85
86/// The report that an idempotent start replay did not apply the display name
87/// this call requested (#211).
88///
89/// Carries both sides so the caller can act on the difference: rename the run
90/// deliberately, use a different idempotency key, or accept the standing name.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct DisplayNameNotApplied {
93    /// The display name this start asked for. Never empty and never absent:
94    /// the report is raised only for a call that actually requested a name, so
95    /// there is always a name that did not get applied.
96    pub requested: String,
97    /// The display name the CACHED start requested for that run (`None` when
98    /// that start asked for no name).
99    ///
100    /// This is what the first start asked for, not a live read: the cache is
101    /// never refreshed, so if the run has since been renamed through the rename
102    /// surface, this is the name it was STARTED with rather than the one it now
103    /// wears. Read it as "the name this key already stood for", and read the
104    /// run itself if you need its current name.
105    pub standing: Option<String>,
106}
107
108/// What a start request is keyed by for SDK-boundary idempotency: the namespace,
109/// the workflow type, the payload's content type and bytes, and BOTH routing
110/// dimensions.
111///
112/// The routing fields are part of the key because they change where the workflow
113/// runs and which queue its activities go to. Replaying a cached handle for a key
114/// reused with a different `task_queue` would silently discard the caller's
115/// second, different intent and hand back the first workflow as though it had
116/// honoured it. The Python SDK keys the same six values, and the cross-SDK
117/// conformance contract requires the two to agree on what a key *means*.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub(crate) struct StartFingerprint {
120    namespace: String,
121    workflow_type: String,
122    content_type: aion_core::ContentType,
123    bytes: Vec<u8>,
124    routing_key: Option<String>,
125    task_queue: Option<String>,
126    idempotency_key: String,
127}
128
129impl StartFingerprint {
130    fn new(
131        namespace: String,
132        workflow_type: String,
133        input: &Payload,
134        routing_key: Option<String>,
135        task_queue: Option<String>,
136        idempotency_key: String,
137    ) -> Self {
138        Self {
139            namespace,
140            workflow_type,
141            content_type: input.content_type().clone(),
142            bytes: input.bytes().to_vec(),
143            routing_key,
144            task_queue,
145            idempotency_key,
146        }
147    }
148
149    pub(crate) fn key(&self) -> &str {
150        &self.idempotency_key
151    }
152}
153
154/// One idempotency-cache entry: the fingerprint the start was keyed by, the
155/// handle to replay, and the display name that start requested (#211).
156///
157/// The display name is stored BESIDE the fingerprint, never inside it. Inside,
158/// it would make two starts differing only in a label two different acts and
159/// break the pre-approved dedupe ruling; absent entirely, a replay could not
160/// tell the caller which name the standing run actually wears. Beside is what
161/// lets the replay dedupe AND report.
162#[derive(Clone, Debug)]
163pub(crate) struct CachedStart {
164    fingerprint: StartFingerprint,
165    handle: WorkflowHandle,
166    display_name: Option<String>,
167}
168
169impl CachedStart {
170    pub(crate) const fn new(
171        fingerprint: StartFingerprint,
172        handle: WorkflowHandle,
173        display_name: Option<String>,
174    ) -> Self {
175        Self {
176            fingerprint,
177            handle,
178            display_name,
179        }
180    }
181
182    pub(crate) const fn fingerprint(&self) -> &StartFingerprint {
183        &self.fingerprint
184    }
185
186    pub(crate) fn into_handle(self) -> WorkflowHandle {
187        self.handle
188    }
189
190    /// The report that `requested` was not applied, or `None` when there is
191    /// nothing to report.
192    ///
193    /// The rule, exactly: a report is raised when this replay ASKED for a name
194    /// (`requested.is_some()`) that the standing run does not already wear —
195    /// whether that run wears a different name or no name at all. Both of those
196    /// callers asked for something the replay could not give them, so both are
197    /// told.
198    ///
199    /// A replay that asked for NO name gets no report, whatever the standing
200    /// run wears. Nothing was requested, so nothing was dropped, and the
201    /// report's absence is precisely the statement that no requested name went
202    /// missing — see [`StartOutcome::display_name_not_applied`]. Raising it
203    /// there would report a drop that never happened and push callers who
204    /// branch on the field into acting on a run they never tried to name.
205    pub(crate) fn display_name_not_applied(
206        &self,
207        requested: Option<&str>,
208    ) -> Option<DisplayNameNotApplied> {
209        let requested = requested?;
210        if self.display_name.as_deref() == Some(requested) {
211            return None;
212        }
213        Some(DisplayNameNotApplied {
214            requested: requested.to_owned(),
215            standing: self.display_name.clone(),
216        })
217    }
218}
219
220fn validate_start_options(opts: &StartOptions) -> Result<(), ClientError> {
221    if opts
222        .idempotency_key
223        .as_ref()
224        .is_some_and(std::string::String::is_empty)
225    {
226        return Err(ClientError::invalid_argument(
227            "idempotency_key must not be empty",
228        ));
229    }
230    // #211: the server REFUSES a present-but-blank display name with
231    // `invalid_input` — it does not reinterpret one as "unnamed", the way it
232    // reinterprets a blank `task_queue` as "no selection". Refusing here too
233    // means a caller who passed `Some("  ")` gets the same answer from the SDK
234    // as from raw gRPC, and gets it before a round trip. `None` is how a caller
235    // says "no name"; absence is what the server accepts as unnamed.
236    if opts
237        .display_name
238        .as_ref()
239        .is_some_and(|name| name.trim().is_empty())
240    {
241        return Err(ClientError::invalid_argument(
242            "display_name must not be blank; omit it to start the run unnamed",
243        ));
244    }
245    Ok(())
246}
247
248impl Client {
249    /// Starts a workflow and returns the assigned workflow and run identifiers.
250    ///
251    /// Returns a [`StartOutcome`] rather than a bare handle so an idempotent
252    /// replay can report a display name it did NOT apply (#211) instead of
253    /// dropping it silently — see [`StartOutcome::display_name_not_applied`].
254    ///
255    /// # Errors
256    ///
257    /// Returns [`ClientError`] when transport, server, or response conversion fails.
258    pub async fn start(
259        &self,
260        workflow_type: impl Into<String>,
261        input: Payload,
262        opts: StartOptions,
263    ) -> Result<StartOutcome, ClientError> {
264        validate_start_options(&opts)?;
265        let idempotency_key = opts.idempotency_key.clone();
266        let routing_key = opts.routing_key.clone();
267        let task_queue = opts.task_queue.clone();
268        // The server trims the name before recording it, so the run wears the
269        // TRIMMED string. Trim here too and the SDK's cached "standing name"
270        // is the name the run actually has — otherwise a replay comparing
271        // `"X"` against a cached `"  X  "` would report a difference the
272        // server had already erased. Blank-after-trim was refused above, so
273        // this can never turn a name into no name.
274        let display_name = opts
275            .display_name
276            .as_deref()
277            .map(str::trim)
278            .map(str::to_owned);
279        let namespace = operation_namespace(self, opts.namespace);
280        let workflow_type = workflow_type.into();
281        let fingerprint = idempotency_key.as_ref().map(|key| {
282            StartFingerprint::new(
283                namespace.clone(),
284                workflow_type.clone(),
285                &input,
286                routing_key.clone(),
287                task_queue.clone(),
288                key.clone(),
289            )
290        });
291        if let Some(fingerprint) = &fingerprint {
292            if let Some(cached) = self.cached_start(fingerprint).await? {
293                // The run already exists and keeps the name it already wears.
294                // If this call asked for a different one, say so rather than
295                // quietly handing back a differently-named run (#211).
296                let not_applied = cached.display_name_not_applied(display_name.as_deref());
297                return Ok(StartOutcome {
298                    handle: cached.into_handle(),
299                    display_name_not_applied: not_applied,
300                });
301            }
302        }
303        let response = self
304            .transport
305            .start_workflow(ProtoStartWorkflowRequest {
306                namespace,
307                workflow_type,
308                input: Some(ProtoPayload::from(input)),
309                routing_key,
310                task_queue,
311                display_name: display_name.clone(),
312            })
313            .await?;
314        let workflow_id = decode_required_workflow_id(response.workflow_id, "start response")?;
315        let run_id = decode_required_run_id(response.run_id, "start response")?;
316        let handle = WorkflowHandle::from_ids(self.clone(), workflow_id, run_id);
317        if let Some(fingerprint) = fingerprint {
318            self.record_start(CachedStart::new(fingerprint, handle.clone(), display_name))
319                .await?;
320        }
321        Ok(StartOutcome {
322            handle,
323            display_name_not_applied: None,
324        })
325    }
326
327    /// Starts a workflow after serializing `input` as JSON.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`ClientError::InvalidArgument`] when serialization fails, or the
332    /// delegated start error otherwise.
333    pub async fn start_typed<T>(
334        &self,
335        workflow_type: impl Into<String>,
336        input: &T,
337        opts: StartOptions,
338    ) -> Result<StartOutcome, ClientError>
339    where
340        T: Serialize + ?Sized,
341    {
342        self.start(workflow_type, to_payload(input)?, opts).await
343    }
344}