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 && let Some(cached) = self.cached_start(fingerprint).await?
293 {
294 // The run already exists and keeps the name it already wears.
295 // If this call asked for a different one, say so rather than
296 // quietly handing back a differently-named run (#211).
297 let not_applied = cached.display_name_not_applied(display_name.as_deref());
298 return Ok(StartOutcome {
299 handle: cached.into_handle(),
300 display_name_not_applied: not_applied,
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}