OXYGENHUB
# bf3d07541368 oxygenhub / crates / hub / src / flow.rs
21.6 KiB · Rust · last changed A queued run says where it stands 1 month ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
38
    if let Err(e) = start_run(app, row, bookmark, state, heads, actor, "push").await {
39
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
40
    }
41
}
42
43
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
44
/// moving it. Returns the new run's number.
45
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
46
    if !enabled(app, row) {
47
        return Err("OxygenFlow is not enabled for this repository".into());
48
    }
49
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
50
    let b = bookmark.to_string();
51
    let found = app
52
        .repos
53
        .read(&repo, move |r, _| {
54
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
55
            Ok(Some((st, r.store().get_state(&st)?.heads)))
56
        })
57
        .await
58
        .map_err(|e| e.to_string())?;
59
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
60
    if heads.is_empty() {
61
        return Err(format!("{bookmark} has no changes yet"));
62
    }
63
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
64
        .await?
65
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
66
}
67
68
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
69
/// run whose checks replace the old ones. Returns the new run's number.
70
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
71
    if !enabled(app, row) {
72
        return Err("OxygenFlow is not enabled for this repository".into());
73
    }
74
    if run.live() {
75
        return Err(format!("run #{} is still {}", run.number, run.status));
76
    }
77
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
78
    if heads.is_empty() {
79
        return Err("the run names no changes".into());
80
    }
81
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
82
        .await?
83
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
84
}
85
86
/// Start a run of the pipeline in the state `heads` make (whose state
87
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
88
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
89
/// whose access the run's subrepo reads are checked against. Returns the
90
/// run's number, or `None` when the state has no pipeline. The caller
91
/// checks that Flow is enabled.
92
pub async fn start_run(
93
    app: &Shared,
94
    row: &db::repos::RepoRow,
95
    bookmark: &str,
96
    state: &str,
97
    heads: &[ChangeHash],
98
    actor: &str,
99
    trigger: &str,
100
) -> Result<Option<i64>, String> {
101
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
102
    let h = heads.to_vec();
103
    let text = app
104
        .repos
105
        .read(&repo, move |r, cache| {
106
            let loaded = cache.load(r, &h)?;
107
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
108
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
109
                _ => None,
110
            })
111
        })
112
        .await
113
        .map_err(|e| format!("reading the pipeline: {e}"))?;
114
    let Some(text) = text else { return Ok(None) };
115
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
116
    hex.sort();
117
    let heads_s = hex.join("+");
118
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
119
    let id = match Pipeline::parse(&text) {
120
        Err(problems) => {
121
            let msg = problems.to_string();
122
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
123
        }
124
        Ok(p) => {
125
            let depths = p.depths();
126
            let jobs: Vec<fdb::NewJob> = p
127
                .order()
128
                .into_iter()
129
                .map(|j| {
130
                    let sb = &p.sandboxes[&j.sandbox];
131
                    fdb::NewJob {
132
                        name: j.name.clone(),
133
                        depth: depths[j.name.as_str()] as i64,
134
                        depends: j.depends_on.clone(),
135
                        spec: serde_json::to_string(j).unwrap_or_default(),
136
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
137
                        runtime: sb.runtime.as_str().into(),
138
                    }
139
                })
140
                .collect();
141
            let hash = hex::encode(p.hash());
142
            let id =
143
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
144
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
145
                for j in &jobs {
146
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
147
                }
148
            }
149
            id
150
        }
151
    };
152
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
153
    Ok(Some(run.number))
154
}
155
156
/// Record a job's check; it links to the run's page by the run's number.
157
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
158
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
159
    let name = format!("flow/{job}");
160
    let c = db::hooks::upsert_check(
161
        &app.db,
162
        row.id,
163
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
164
    )
165
    .await;
166
    if let Ok(c) = c {
167
        let api = crate::api::check_of(&c);
168
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
169
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
170
    }
171
}
172
173
/// The check status a job's status shows as.
174
fn check_status(job_status: &str) -> &'static str {
175
    match job_status {
176
        "queued" => "queued",
177
        "running" => "running",
178
        "passed" => "passed",
179
        "skipped" => "skipped",
180
        _ => "failed",
181
    }
182
}
183
184
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
185
    let Some(t) = oxy_server::bearer_token(headers) else {
186
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
187
    };
188
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
189
        Ok(())
190
    } else {
191
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
192
    }
193
}
194
195
/// `POST /api/v1/flow/claim`.
196
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
197
    runner_auth(&app, &headers)?;
198
    let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
199
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
200
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
201
    let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else {
202
        return Ok(StatusCode::NO_CONTENT.into_response());
203
    };
204
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
205
    let row =
206
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
207
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
208
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
209
    let claim = Claim {
210
        job_id: job.id,
211
        run_id: run.id,
212
        repo: format!("{}/{}", row.owner, row.name),
213
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
214
        token: secret,
215
        heads: run.head_list().into_iter().map(String::from).collect(),
216
        pipeline_hash: run.pipeline_hash.clone(),
217
        job: serde_json::from_str(&job.spec).map_err(bad)?,
218
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
219
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
220
        subrepos: vec![],
221
    };
222
    let mut claim = claim;
223
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
224
    if !ids.is_empty() {
225
        fdb::set_also_read(&app.db, job.id, &ids).await?;
226
    }
227
    claim.subrepos = inputs;
228
    Ok(Json(claim).into_response())
229
}
230
231
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
232
async fn pins_in(
233
    app: &Shared,
234
    name: crate::repos::RepoName,
235
    heads: Vec<oxy_core::hash::ChangeHash>,
236
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
237
    app.repos
238
        .read(&name, move |repo, cache| {
239
            let loaded = cache.load(repo, &heads)?;
240
            let mut out = Vec::new();
241
            for (path, f) in &loaded.snapshot.files {
242
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
243
                    continue;
244
                }
245
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
246
                    if let Some(b) = blobs.first() {
247
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
248
                            .read_blob(b)
249
                            .map_err(crate::repos::RepoError::Oxy)
250
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
251
                        {
252
                            out.push((path.clone(), id, heads));
253
                        }
254
                    }
255
                }
256
            }
257
            Ok(out)
258
        })
259
        .await
260
        .unwrap_or_default()
261
}
262
263
/// How deep subrepos of subrepos are followed.
264
const MAX_SUBREPO_DEPTH: usize = 3;
265
266
/// The Oxygen subrepos the run's state pins, and those their states pin in
267
/// turn (parents first, paths joined), that live on this hub and that the
268
/// run's pusher may read: what the job's workspace needs beside its own
269
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
270
/// then fails on the missing files with an ordinary error.
271
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
272
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
273
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
274
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
275
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
276
    let mut inputs = Vec::new();
277
    let mut ids = Vec::new();
278
    // (path prefix, repository, heads, depth), parents before children.
279
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
280
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
281
        if depth == MAX_SUBREPO_DEPTH {
282
            continue;
283
        }
284
        for (path, id, pinned) in pins_in(app, name, heads).await {
285
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
286
            for other in &rows {
287
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
288
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
289
                if !matches {
290
                    continue;
291
                }
292
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
293
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
294
                    inputs.push(flow_core::api::SubrepoInput {
295
                        path: path.clone(),
296
                        repo: format!("{}/{}", other.owner, other.name),
297
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
298
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
299
                    });
300
                    if !ids.contains(&other.id) {
301
                        ids.push(other.id);
302
                    }
303
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
304
                }
305
                break;
306
            }
307
        }
308
    }
309
    (inputs, ids)
310
}
311
312
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
313
    match fdb::job(&app.db, id).await? {
314
        Some(j) if j.status == "running" => Ok(j),
315
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
316
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
317
    }
318
}
319
320
/// `POST /api/v1/flow/jobs/{id}/log`.
321
pub async fn log(
322
    State(app): State<Shared>,
323
    headers: HeaderMap,
324
    Path(id): Path<i64>,
325
    Json(mut batch): Json<LogBatch>,
326
) -> Result<StatusCode, ApiError> {
327
    runner_auth(&app, &headers)?;
328
    let job = running_job(&app, id).await?;
329
    for r in &mut batch.records {
330
        if r.text.len() > MAX_RECORD_TEXT {
331
            let mut cut = MAX_RECORD_TEXT;
332
            while !r.text.is_char_boundary(cut) {
333
                cut -= 1;
334
            }
335
            r.text.truncate(cut);
336
        }
337
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
338
            r.stream = "console".into();
339
        }
340
    }
341
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
342
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
343
        tracing::info!("flow: job {} reached the log limit", job.id);
344
    }
345
    Ok(StatusCode::NO_CONTENT)
346
}
347
348
/// `POST /api/v1/flow/jobs/{id}/finish`.
349
pub async fn finish(
350
    State(app): State<Shared>,
351
    headers: HeaderMap,
352
    Path(id): Path<i64>,
353
    Json(f): Json<Finish>,
354
) -> Result<StatusCode, ApiError> {
355
    runner_auth(&app, &headers)?;
356
    let job = running_job(&app, id).await?;
357
    let status = match (f.exit_code, &f.error) {
358
        (Some(0), None) => "passed",
359
        (Some(_), _) => "failed",
360
        (None, _) => "error",
361
    };
362
    let (receipt, receipt_hash) = match &f.receipt {
363
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
364
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
365
        }
366
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
367
        None => (String::new(), String::new()),
368
    };
369
    let error = f.error.clone().unwrap_or_default();
370
    let changed = fdb::finish(
371
        &app.db,
372
        job.id,
373
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
374
    )
375
    .await?;
376
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
377
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
378
            for j in changed {
379
                let summary = match j.status.as_str() {
380
                    "passed" => format!("passed in {}", duration(&j)),
381
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
382
                    "skipped" => "skipped: a dependency did not pass".into(),
383
                    _ => format!("could not run: {}", j.error),
384
                };
385
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
386
            }
387
        }
388
    }
389
    Ok(StatusCode::NO_CONTENT)
390
}
391
392
/// A job's wall time, as the pages show it.
393
pub fn duration(j: &fdb::Job) -> String {
394
    match (j.started_ms, j.finished_ms) {
395
        (Some(a), Some(b)) => human_ms(b - a),
396
        (Some(a), None) => human_ms(db::now_ms() - a),
397
        _ => "–".into(),
398
    }
399
}
400
401
pub fn human_ms(ms: i64) -> String {
402
    let ms = ms.max(0);
403
    if ms < 1000 {
404
        format!("{ms} ms")
405
    } else if ms < 60_000 {
406
        format!("{:.1} s", ms as f64 / 1000.0)
407
    } else {
408
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
409
    }
410
}
411
412
/// Where a live run stands among the runners' work: what its viewers may
413
/// know of it. Jobs of other repositories are named only when those are
414
/// public; the rest are counted.
415
#[derive(Clone, Debug, Default, PartialEq, Eq)]
416
pub struct QueueInfo {
417
    /// 1-based place of the run's first queued job among all queued jobs.
418
    pub position: Option<usize>,
419
    /// Jobs running now, and queued jobs ahead of the run's first one.
420
    pub running: Vec<String>,
421
    pub ahead: Vec<String>,
422
    /// Of those, jobs in repositories the page may not name.
423
    pub hidden: usize,
424
}
425
426
impl QueueInfo {
427
    /// "3rd in line" and the like.
428
    pub fn place(&self) -> Option<String> {
429
        self.position.map(|n| {
430
            let suffix = match (n % 10, n % 100) {
431
                (1, 11) | (2, 12) | (3, 13) => "th",
432
                (1, _) => "st",
433
                (2, _) => "nd",
434
                (3, _) => "rd",
435
                _ => "th",
436
            };
437
            format!("{n}{suffix} in line")
438
        })
439
    }
440
}
441
442
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
443
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
444
    let mut info = QueueInfo::default();
445
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
446
    info.position = first.map(|i| i + 1);
447
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
448
    let mut queued_seen = 0;
449
    for e in queue {
450
        if e.run_id == run_id {
451
            continue;
452
        }
453
        let ahead = match e.status.as_str() {
454
            "running" => true,
455
            _ => {
456
                queued_seen += 1;
457
                first.is_some_and(|f| queued_seen <= f)
458
            }
459
        };
460
        if !ahead {
461
            continue;
462
        }
463
        if e.repo_id != repo_id && e.visibility != "public" {
464
            info.hidden += 1;
465
        } else if e.status == "running" {
466
            info.running.push(label(e));
467
        } else {
468
            info.ahead.push(label(e));
469
        }
470
    }
471
    info
472
}
473
474
#[cfg(test)]
475
mod queue_tests {
476
    use super::*;
477
478
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
479
        fdb::QueueEntry {
480
            job_id,
481
            run_id,
482
            repo_id,
483
            owner: "o".into(),
484
            name: format!("r{repo_id}"),
485
            visibility: vis.into(),
486
            run_number: run_id,
487
            job: job.into(),
488
            status: status.into(),
489
        }
490
    }
491
492
    #[test]
493
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
494
        // Running first, then queued in claim order (as `fdb::queue` returns them).
495
        let q = vec![
496
            e(1, 10, 1, "public", "build", "running"),
497
            e(2, 11, 2, "private", "test", "running"),
498
            e(3, 10, 1, "public", "test", "queued"),
499
            e(4, 12, 3, "private", "lint", "queued"),
500
            e(5, 13, 4, "public", "build", "queued"),
501
            e(6, 13, 4, "public", "test", "queued"),
502
            e(7, 14, 5, "public", "late", "queued"),
503
        ];
504
        let info = queue_info(&q, 4, 13);
505
        assert_eq!(info.position, Some(3));
506
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
507
        assert_eq!(info.running, ["o/r1 #10 build"]);
508
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
509
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
510
        // A private repository's own jobs are named to its viewers.
511
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
512
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
513
        // A run with nothing queued has no place.
514
        assert_eq!(queue_info(&q, 1, 99).position, None);
515
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
516
        assert_eq!(
517
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
518
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
519
        );
520
    }
521
}