Skip to main content

aion_server/worker/
declared_body_ambiguity.rs

1//! The refusal an operator reads when retained package versions disagree about
2//! an action's declared body — and the remedy it names.
3//!
4//! Content-hash namespacing keeps every deployed version of a document alive at
5//! once, so editing a declared command body and deploying again leaves TWO
6//! retained versions declaring the same action with different commands. Running
7//! either would guess which deploy the workflow meant, so the dispatch is
8//! refused.
9//!
10//! Refusing is the easy half. The refusal used to say *"redeploy so one body
11//! remains"*, which cannot be followed: redeploying is exactly what created the
12//! second body, and the route-active version cannot be unloaded (the deploy API
13//! answers `RouteActive`). The operator is then stuck holding a terminal error
14//! whose only instruction makes the problem worse.
15//!
16//! What actually clears it is retiring the SUPERSEDED versions — which needs
17//! their content hashes, which the operator does not have and the message did
18//! not carry. So the message carries them now, as runnable `aion unload`
19//! commands, and distinguishes the two situations that need different moves:
20//! a superseded version left behind (retire it) from two route-active packages
21//! that genuinely disagree (nothing to retire — the action name is shared).
22
23use std::collections::BTreeSet;
24
25/// One retained package version that declares a body for the ambiguous action,
26/// carrying enough identity to be named in a command the operator can run.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct DeclaringVersion {
29    /// Content hash of the package version, in the canonical 64-character form.
30    /// Never abbreviated: the deploy API parses the whole hash and refuses a
31    /// short one, so a truncated hash would print a command that cannot run.
32    pub content_hash: String,
33    /// Every workflow type this exact version implements, sorted. Unload is
34    /// keyed by `(workflow_type, content_hash)`, so a package archive carrying
35    /// several entry modules needs one command per type.
36    pub workflow_types: Vec<String>,
37    /// Whether new starts still route to this version. A route-active version
38    /// cannot be unloaded, so it is the one the operator keeps.
39    pub route_active: bool,
40    /// Which distinct body this version declares, as an index into the order
41    /// the bodies were first seen. Versions sharing an index agree.
42    pub body: usize,
43}
44
45/// Builds the terminal refusal for `action` on `task_queue`, given every
46/// retained version that declares a body for it.
47///
48/// `declaring` is expected to hold at least two distinct `body` indices — that
49/// is what makes the lookup ambiguous — but the text is written so a shorter
50/// list still reads as a true sentence rather than a malformed one.
51#[must_use]
52pub fn ambiguous_body_refusal(
53    action: &str,
54    task_queue: &str,
55    declaring: &[DeclaringVersion],
56) -> String {
57    let distinct: BTreeSet<usize> = declaring.iter().map(|version| version.body).collect();
58    let live: BTreeSet<usize> = declaring
59        .iter()
60        .filter(|version| version.route_active)
61        .map(|version| version.body)
62        .collect();
63
64    let remedy = remedy_for(declaring, &live);
65
66    format!(
67        "terminal:action `{action}` on task queue `{task_queue}` declares {bodies} different \
68         bodies across {versions} retained package versions; refusing to guess which deploy this \
69         run meant. {remedy}",
70        bodies = distinct.len(),
71        versions = declaring.len(),
72    )
73}
74
75/// The sentence that tells the operator what to actually do, chosen from what
76/// the retained set looks like. `live` holds the body indices that route-active
77/// versions carry.
78fn remedy_for(declaring: &[DeclaringVersion], live: &BTreeSet<usize>) -> String {
79    if live.len() > 1 {
80        // Nothing is superseded here: two packages that new starts can both
81        // reach disagree about what this action name means. Unloading either
82        // would break a live route, so the remedy is an authoring change.
83        return format!(
84            "Every disagreeing version is route-active, so there is no superseded version to \
85             retire — {live_versions} are all reachable by new starts and declare different \
86             commands for the same action name. Give the action a distinct name in each document, \
87             or declare the same body in both.",
88            live_versions = route_active_names(declaring),
89        );
90    }
91
92    let superseded = unload_commands(declaring);
93    if superseded.is_empty() {
94        // Every declaring version is route-active and they agree, so the
95        // ambiguity is not between deploys — say what was actually seen rather
96        // than prescribing a move that has no target.
97        return "No superseded version is retained, so there is nothing to unload; the \
98                disagreement is inside the route-active set and needs the documents themselves \
99                reconciled."
100            .to_owned();
101    }
102
103    if live.is_empty() {
104        // Nothing routes new starts any more: the run in hand came from a
105        // version that has been superseded out of routing entirely. Deploying
106        // first is what gives the operator a version to keep.
107        return format!(
108            "No retained version is route-active, so deploy the document you mean first — that \
109             makes its version the one new starts reach — then retire the rest and start the run \
110             again: {superseded}.",
111            superseded = superseded.join(", "),
112        );
113    }
114
115    format!(
116        "The route-active version already carries the body new starts use; retiring the \
117         superseded ones is what leaves a single body. Redeploying adds a version rather than \
118         removing one, so unload instead, then start the run again: {superseded}. Kept, because \
119         new starts route to it: {kept}.",
120        superseded = superseded.join(", "),
121        kept = route_active_names(declaring),
122    )
123}
124
125/// One runnable `aion unload` command per (type, version) catalog entry that is
126/// safe to retire, in the order the versions were given.
127///
128/// Every superseded entry is listed. A cap would drop exactly the command the
129/// operator needs, and a silent one would read as a complete instruction.
130fn unload_commands(declaring: &[DeclaringVersion]) -> Vec<String> {
131    declaring
132        .iter()
133        .filter(|version| !version.route_active)
134        .flat_map(|version| {
135            version.workflow_types.iter().map(move |workflow_type| {
136                format!(
137                    "`aion unload {workflow_type} {hash}`",
138                    hash = version.content_hash
139                )
140            })
141        })
142        .collect()
143}
144
145/// Names the route-active versions as `type@hash`, one entry per workflow type
146/// so the reader can match a name to a document.
147fn route_active_names(declaring: &[DeclaringVersion]) -> String {
148    let names: Vec<String> = declaring
149        .iter()
150        .filter(|version| version.route_active)
151        .flat_map(|version| {
152            version.workflow_types.iter().map(move |workflow_type| {
153                format!("{workflow_type}@{hash}", hash = version.content_hash)
154            })
155        })
156        .collect();
157    if names.is_empty() {
158        // Unreachable from the call sites, which both test the same predicate
159        // first. Named rather than left as an empty gap so a refactor cannot
160        // produce a sentence that trails off.
161        return "no version".to_owned();
162    }
163    names.join(", ")
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{DeclaringVersion, ambiguous_body_refusal};
169
170    const OLD: &str = "1111111111111111111111111111111111111111111111111111111111111111";
171    const NEW: &str = "2222222222222222222222222222222222222222222222222222222222222222";
172
173    fn version(hash: &str, types: &[&str], route_active: bool, body: usize) -> DeclaringVersion {
174        DeclaringVersion {
175            content_hash: hash.to_owned(),
176            workflow_types: types.iter().map(|name| (*name).to_owned()).collect(),
177            route_active,
178            body,
179        }
180    }
181
182    #[test]
183    fn the_remedy_names_the_superseded_version_as_a_runnable_command() {
184        let refusal = ambiguous_body_refusal(
185            "find_repositories",
186            "local",
187            &[
188                version(OLD, &["git_status_sweep"], false, 0),
189                version(NEW, &["git_status_sweep"], true, 1),
190            ],
191        );
192        assert!(refusal.starts_with("terminal:"), "{refusal}");
193        assert!(
194            refusal.contains(&format!("`aion unload git_status_sweep {OLD}`")),
195            "the superseded version must be named as a command: {refusal}"
196        );
197        assert!(
198            !refusal.contains(&format!("`aion unload git_status_sweep {NEW}`")),
199            "the route-active version cannot be unloaded and must not be told to: {refusal}"
200        );
201        assert!(
202            refusal.contains(&format!("git_status_sweep@{NEW}")),
203            "the kept version must be identified: {refusal}"
204        );
205    }
206
207    #[test]
208    fn the_remedy_never_tells_the_operator_to_redeploy_into_the_problem() {
209        // The defect this text replaced: "redeploy so one body remains" is the
210        // move that created the second body.
211        let refusal = ambiguous_body_refusal(
212            "find_repositories",
213            "local",
214            &[
215                version(OLD, &["sweep"], false, 0),
216                version(NEW, &["sweep"], true, 1),
217            ],
218        );
219        assert!(
220            !refusal.contains("redeploy so one body remains"),
221            "{refusal}"
222        );
223        assert!(
224            refusal.contains("Redeploying adds a version rather than removing one"),
225            "the message must say why the obvious move is wrong: {refusal}"
226        );
227    }
228
229    #[test]
230    fn a_version_with_several_entry_types_earns_one_command_per_type() {
231        let refusal = ambiguous_body_refusal(
232            "build",
233            "local",
234            &[
235                version(OLD, &["alpha", "beta"], false, 0),
236                version(NEW, &["alpha"], true, 1),
237            ],
238        );
239        assert!(
240            refusal.contains(&format!("`aion unload alpha {OLD}`")),
241            "{refusal}"
242        );
243        assert!(
244            refusal.contains(&format!("`aion unload beta {OLD}`")),
245            "{refusal}"
246        );
247    }
248
249    #[test]
250    fn two_route_active_packages_are_told_to_reconcile_not_to_unload() {
251        let refusal = ambiguous_body_refusal(
252            "build",
253            "local",
254            &[
255                version(OLD, &["alpha"], true, 0),
256                version(NEW, &["beta"], true, 1),
257            ],
258        );
259        assert!(
260            !refusal.contains("aion unload"),
261            "unloading a route-active version is refused, so it must not be prescribed: {refusal}"
262        );
263        assert!(
264            refusal.contains("distinct name"),
265            "the remedy for a live collision is an authoring change: {refusal}"
266        );
267        assert!(refusal.contains(&format!("alpha@{OLD}")), "{refusal}");
268        assert!(refusal.contains(&format!("beta@{NEW}")), "{refusal}");
269    }
270
271    #[test]
272    fn with_nothing_route_active_the_deploy_comes_before_the_unload() -> Result<(), String> {
273        let refusal = ambiguous_body_refusal(
274            "build",
275            "local",
276            &[
277                version(OLD, &["alpha"], false, 0),
278                version(NEW, &["alpha"], false, 1),
279            ],
280        );
281        let deploy = refusal
282            .find("deploy the document you mean first")
283            .ok_or_else(|| format!("the deploy step must be named: {refusal}"))?;
284        let unload = refusal
285            .find("aion unload")
286            .ok_or_else(|| format!("the unload step must be named: {refusal}"))?;
287        assert!(
288            deploy < unload,
289            "deploy must be prescribed before unload: {refusal}"
290        );
291        Ok(())
292    }
293
294    #[test]
295    fn the_counts_report_bodies_and_versions_separately() {
296        // Three versions, two distinct bodies: the operator needs both numbers
297        // to know that retiring one version is not enough.
298        let refusal = ambiguous_body_refusal(
299            "build",
300            "local",
301            &[
302                version(OLD, &["alpha"], false, 0),
303                version(NEW, &["alpha"], false, 0),
304                version(
305                    "3333333333333333333333333333333333333333333333333333333333333333",
306                    &["alpha"],
307                    true,
308                    1,
309                ),
310            ],
311        );
312        assert!(
313            refusal.contains("declares 2 different bodies across 3 retained package versions"),
314            "{refusal}"
315        );
316    }
317}