Skip to content

Commit 30e8d09

Browse files
committed
queue-protocol 0.3.1: deferred dispatch (run_at, expire_after_secs, enqueue warning)
1 parent 26faf0a commit 30e8d09

10 files changed

Lines changed: 301 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ This file tracks notable changes to the workspace tag stream
55
source of truth for individual crate bumps. Tags map roughly to
66
"workspace cuts" — a tag may bump multiple crates at once.
77

8+
## v0.5.9 — deferred queue dispatch (2026-06-13)
9+
10+
### Added
11+
12+
`animus-queue-protocol` 0.3.0 -> 0.3.1 (additive, backward compatible):
13+
14+
- `QueueEnqueueRequest.run_at: Option<String>` — RFC 3339 earliest-dispatch
15+
time. When set and in the future, the entry is enqueued deferred: it
16+
stays `pending` but is excluded from `queue/lease` until the instant
17+
passes. `None` preserves dispatch-ASAP behavior.
18+
- `QueueEnqueueRequest.expire_after_secs: Option<u64>` — grace window after
19+
`run_at`; a still-pending deferred entry past `run_at + expire_after_secs`
20+
is dropped on sweep instead of dispatched late. `None` = never expire.
21+
- `QueueEnqueueResponse.warning: Option<String>` — non-fatal advisory. Set
22+
(most commonly) when another entry already exists for the same subject;
23+
the duplicate is still enqueued (deferred enqueues are never deduped) and
24+
the caller decides whether to drop it.
25+
- `QueueEntry.run_at` / `QueueEntry.expire_after_secs` — surfaced on
26+
list/lease so callers can distinguish scheduled-for-later entries.
27+
- `QueueStats.deferred: usize` — subset of `pending` not yet leasable.
28+
29+
All new fields use serde defaults / `skip_serializing_if`, so older
30+
clients and stored payloads round-trip unchanged.
31+
832
## v0.5.7 — restore `subject/delete` + plugin-runtime subject helpers (2026-06-07)
933

1034
### Restored

animus-queue-protocol/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "animus-queue-protocol"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
edition = "2021"
55
license = "MIT"
66
authors = ["Launchapp.dev"]
@@ -17,6 +17,7 @@ schemars = { version = "1.0" }
1717

1818
[dev-dependencies]
1919
tempfile = "3"
20+
chrono = "0.4"
2021

2122
[[bin]]
2223
name = "animus-queue-protocol-export-schema"

animus-queue-protocol/src/lib.rs

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize};
2626
pub const KIND: &str = "queue";
2727

2828
/// Per-crate semver protocol version.
29-
pub const PROTOCOL_VERSION: &str = "0.3.0";
29+
pub const PROTOCOL_VERSION: &str = "0.3.1";
3030

3131
/// Add a dispatch to the queue.
3232
pub const METHOD_QUEUE_ENQUEUE: &str = "queue/enqueue";
@@ -90,20 +90,45 @@ pub mod completion_status {
9090
pub struct QueueEnqueueRequest {
9191
/// Full dispatch envelope to enqueue.
9292
pub subject_dispatch: SubjectDispatch,
93+
/// Optional RFC 3339 earliest-dispatch time. When set and in the
94+
/// future, the entry is enqueued as deferred: it stays in
95+
/// [`status::PENDING`] but is excluded from [`METHOD_QUEUE_LEASE`]
96+
/// until this instant passes. `None` means dispatch as soon as
97+
/// capacity allows (today's behavior).
98+
#[serde(default, skip_serializing_if = "Option::is_none")]
99+
pub run_at: Option<String>,
100+
/// Optional grace window, in seconds, applied after `run_at`. If a
101+
/// deferred entry is still pending past `run_at + expire_after_secs`
102+
/// (e.g. the daemon was down through its window), the plugin drops it
103+
/// on its next sweep instead of dispatching late. `None` means never
104+
/// expire — always fire late whenever the daemon next leases. Ignored
105+
/// when `run_at` is `None`.
106+
#[serde(default, skip_serializing_if = "Option::is_none")]
107+
pub expire_after_secs: Option<u64>,
93108
}
94109

95110
/// Response for [`METHOD_QUEUE_ENQUEUE`].
96111
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
97112
pub struct QueueEnqueueResponse {
98-
/// `true` if a new entry was created. `false` if the dispatch was
99-
/// rejected as a duplicate of an existing pending/assigned entry
100-
/// (idempotent enqueue).
113+
/// `true` if a new entry was created. For immediate (non-deferred)
114+
/// enqueues this stays idempotent: `false` if the dispatch was
115+
/// rejected as a duplicate of an existing pending/assigned entry.
116+
/// Deferred enqueues (`run_at` set) are always created — scheduling
117+
/// the same subject for distinct times is legitimate — so `enqueued`
118+
/// is `true` and any collision is surfaced via `warning` instead.
101119
pub enqueued: bool,
102120
/// Stable entry id assigned by the plugin. Used by all subsequent
103121
/// mutation calls.
104122
pub entry_id: String,
105123
/// Convenience: the subject id from the dispatch envelope.
106124
pub subject_id: String,
125+
/// Non-fatal advisory. Set when the enqueue succeeded but the caller
126+
/// may want to reconsider — most commonly that another pending,
127+
/// deferred, or assigned entry already exists for this subject. The
128+
/// duplicate is still enqueued; the caller (agent or operator) decides
129+
/// whether to drop it. `None` when there is nothing to flag.
130+
#[serde(default, skip_serializing_if = "Option::is_none")]
131+
pub warning: Option<String>,
107132
}
108133

109134
/// Request for [`METHOD_QUEUE_LIST`].
@@ -158,19 +183,35 @@ pub struct QueueEntry {
158183
/// RFC 3339 hold timestamp.
159184
#[serde(default, skip_serializing_if = "Option::is_none")]
160185
pub held_at: Option<String>,
186+
/// RFC 3339 earliest-dispatch time for a deferred entry. While `now`
187+
/// is before this instant the entry is pending-but-not-leasable.
188+
/// `None` for ordinary (dispatch-ASAP) entries.
189+
#[serde(default, skip_serializing_if = "Option::is_none")]
190+
pub run_at: Option<String>,
191+
/// Grace window in seconds after `run_at` before the entry is expired
192+
/// and dropped on sweep. `None` means never expire. Ignored when
193+
/// `run_at` is `None`.
194+
#[serde(default, skip_serializing_if = "Option::is_none")]
195+
pub expire_after_secs: Option<u64>,
161196
}
162197

163198
/// Queue aggregate counts.
164199
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
165200
pub struct QueueStats {
166201
/// Total entries.
167202
pub total: usize,
168-
/// Pending entries.
203+
/// Pending entries (includes deferred entries not yet leasable).
169204
pub pending: usize,
170205
/// Assigned entries.
171206
pub assigned: usize,
172207
/// Held entries.
173208
pub held: usize,
209+
/// Subset of `pending` that is deferred — `run_at` is still in the
210+
/// future, so these are not yet leasable. Lets callers distinguish
211+
/// "scheduled for later" from "ready to dispatch" without inspecting
212+
/// every entry. `0` on backends that predate deferred dispatch.
213+
#[serde(default)]
214+
pub deferred: usize,
174215
}
175216

176217
/// Request for [`METHOD_QUEUE_LEASE`].
@@ -353,12 +394,105 @@ mod tests {
353394
enqueued: true,
354395
entry_id: "ent_1".into(),
355396
subject_id: "TASK-1".into(),
397+
warning: None,
356398
};
357399
let v = serde_json::to_value(&r).unwrap();
400+
// `warning: None` is omitted from the wire and legacy responses
401+
// without the field decode cleanly.
402+
assert!(v.get("warning").is_none());
358403
let back: QueueEnqueueResponse = serde_json::from_value(v).unwrap();
359404
assert_eq!(back, r);
360405
}
361406

407+
#[test]
408+
fn enqueue_response_carries_warning() {
409+
let r = QueueEnqueueResponse {
410+
enqueued: true,
411+
entry_id: "ent_2".into(),
412+
subject_id: "TASK-2".into(),
413+
warning: Some("subject TASK-2 already has 1 queued entry".into()),
414+
};
415+
let v = serde_json::to_value(&r).unwrap();
416+
assert_eq!(
417+
v.get("warning").and_then(|w| w.as_str()),
418+
Some("subject TASK-2 already has 1 queued entry")
419+
);
420+
let back: QueueEnqueueResponse = serde_json::from_value(v).unwrap();
421+
assert_eq!(back, r);
422+
}
423+
424+
fn sample_dispatch(id: &str) -> SubjectDispatch {
425+
use animus_subject_protocol::SubjectRef;
426+
let requested_at = "2030-01-01T00:00:00Z"
427+
.parse::<chrono::DateTime<chrono::Utc>>()
428+
.unwrap();
429+
SubjectDispatch::for_subject_with_metadata(
430+
SubjectRef::task(id),
431+
"standard",
432+
"test",
433+
requested_at,
434+
)
435+
}
436+
437+
#[test]
438+
fn enqueue_request_round_trips_deferred() {
439+
let req = QueueEnqueueRequest {
440+
subject_dispatch: sample_dispatch("TASK-9"),
441+
run_at: Some("2030-01-01T15:00:00Z".into()),
442+
expire_after_secs: Some(600),
443+
};
444+
let v = serde_json::to_value(&req).unwrap();
445+
assert_eq!(
446+
v.get("run_at").and_then(|t| t.as_str()),
447+
Some("2030-01-01T15:00:00Z")
448+
);
449+
assert_eq!(v.get("expire_after_secs").and_then(|t| t.as_u64()), Some(600));
450+
let back: QueueEnqueueRequest = serde_json::from_value(v).unwrap();
451+
assert_eq!(back, req);
452+
}
453+
454+
#[test]
455+
fn enqueue_request_omits_deferral_when_immediate() {
456+
let req = QueueEnqueueRequest {
457+
subject_dispatch: sample_dispatch("TASK-10"),
458+
run_at: None,
459+
expire_after_secs: None,
460+
};
461+
let v = serde_json::to_value(&req).unwrap();
462+
assert!(v.get("run_at").is_none());
463+
assert!(v.get("expire_after_secs").is_none());
464+
// Legacy enqueue payloads (no deferral fields) still decode.
465+
let legacy = serde_json::json!({ "subject_dispatch": v.get("subject_dispatch").unwrap() });
466+
let back: QueueEnqueueRequest = serde_json::from_value(legacy).unwrap();
467+
assert_eq!(back.run_at, None);
468+
assert_eq!(back.expire_after_secs, None);
469+
}
470+
471+
#[test]
472+
fn entry_round_trips_with_deferral_fields() {
473+
let entry = QueueEntry {
474+
entry_id: "ent_3".into(),
475+
subject_id: "TASK-11".into(),
476+
task_id: Some("TASK-11".into()),
477+
subject_dispatch: sample_dispatch("TASK-11"),
478+
status: status::PENDING.into(),
479+
workflow_id: None,
480+
enqueued_at: "2030-01-01T00:00:00Z".into(),
481+
assigned_at: None,
482+
held_at: None,
483+
run_at: Some("2030-01-01T15:00:00Z".into()),
484+
expire_after_secs: Some(600),
485+
};
486+
let v = serde_json::to_value(&entry).unwrap();
487+
let back: QueueEntry = serde_json::from_value(v).unwrap();
488+
assert_eq!(back, entry);
489+
// Stats default keeps `deferred` at zero for legacy payloads.
490+
let legacy_stats: QueueStats =
491+
serde_json::from_value(serde_json::json!({ "total": 1, "pending": 1, "assigned": 0, "held": 0 }))
492+
.unwrap();
493+
assert_eq!(legacy_stats.deferred, 0);
494+
}
495+
362496
#[test]
363497
fn release_pending_round_trips() {
364498
let p = QueueReleasePendingParams {

schemas/animus-queue-protocol/QueueEnqueueRequest.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44
"description": "Request for [`METHOD_QUEUE_ENQUEUE`].",
55
"type": "object",
66
"properties": {
7+
"expire_after_secs": {
8+
"description": "Optional grace window, in seconds, applied after `run_at`. If a\ndeferred entry is still pending past `run_at + expire_after_secs`\n(e.g. the daemon was down through its window), the plugin drops it\non its next sweep instead of dispatching late. `None` means never\nexpire — always fire late whenever the daemon next leases. Ignored\nwhen `run_at` is `None`.",
9+
"type": [
10+
"integer",
11+
"null"
12+
],
13+
"format": "uint64",
14+
"minimum": 0
15+
},
16+
"run_at": {
17+
"description": "Optional RFC 3339 earliest-dispatch time. When set and in the\nfuture, the entry is enqueued as deferred: it stays in\n[`status::PENDING`] but is excluded from [`METHOD_QUEUE_LEASE`]\nuntil this instant passes. `None` means dispatch as soon as\ncapacity allows (today's behavior).",
18+
"type": [
19+
"string",
20+
"null"
21+
]
22+
},
723
"subject_dispatch": {
824
"description": "Full dispatch envelope to enqueue.",
925
"$ref": "#/$defs/SubjectDispatch"

schemas/animus-queue-protocol/QueueEnqueueResponse.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "object",
66
"properties": {
77
"enqueued": {
8-
"description": "`true` if a new entry was created. `false` if the dispatch was\nrejected as a duplicate of an existing pending/assigned entry\n(idempotent enqueue).",
8+
"description": "`true` if a new entry was created. For immediate (non-deferred)\nenqueues this stays idempotent: `false` if the dispatch was\nrejected as a duplicate of an existing pending/assigned entry.\nDeferred enqueues (`run_at` set) are always created — scheduling\nthe same subject for distinct times is legitimate — so `enqueued`\nis `true` and any collision is surfaced via `warning` instead.",
99
"type": "boolean"
1010
},
1111
"entry_id": {
@@ -15,6 +15,13 @@
1515
"subject_id": {
1616
"description": "Convenience: the subject id from the dispatch envelope.",
1717
"type": "string"
18+
},
19+
"warning": {
20+
"description": "Non-fatal advisory. Set when the enqueue succeeded but the caller\nmay want to reconsider — most commonly that another pending,\ndeferred, or assigned entry already exists for this subject. The\nduplicate is still enqueued; the caller (agent or operator) decides\nwhether to drop it. `None` when there is nothing to flag.",
21+
"type": [
22+
"string",
23+
"null"
24+
]
1825
}
1926
},
2027
"required": [

schemas/animus-queue-protocol/QueueEntry.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,29 @@
1919
"description": "Stable entry id (unique within the project queue). Mutation calls\ntarget this id.",
2020
"type": "string"
2121
},
22+
"expire_after_secs": {
23+
"description": "Grace window in seconds after `run_at` before the entry is expired\nand dropped on sweep. `None` means never expire. Ignored when\n`run_at` is `None`.",
24+
"type": [
25+
"integer",
26+
"null"
27+
],
28+
"format": "uint64",
29+
"minimum": 0
30+
},
2231
"held_at": {
2332
"description": "RFC 3339 hold timestamp.",
2433
"type": [
2534
"string",
2635
"null"
2736
]
2837
},
38+
"run_at": {
39+
"description": "RFC 3339 earliest-dispatch time for a deferred entry. While `now`\nis before this instant the entry is pending-but-not-leasable.\n`None` for ordinary (dispatch-ASAP) entries.",
40+
"type": [
41+
"string",
42+
"null"
43+
]
44+
},
2945
"status": {
3046
"description": "Status value from [`status`].",
3147
"type": "string"

schemas/animus-queue-protocol/QueueLeaseResponse.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,29 @@
3535
"description": "Stable entry id (unique within the project queue). Mutation calls\ntarget this id.",
3636
"type": "string"
3737
},
38+
"expire_after_secs": {
39+
"description": "Grace window in seconds after `run_at` before the entry is expired\nand dropped on sweep. `None` means never expire. Ignored when\n`run_at` is `None`.",
40+
"type": [
41+
"integer",
42+
"null"
43+
],
44+
"format": "uint64",
45+
"minimum": 0
46+
},
3847
"held_at": {
3948
"description": "RFC 3339 hold timestamp.",
4049
"type": [
4150
"string",
4251
"null"
4352
]
4453
},
54+
"run_at": {
55+
"description": "RFC 3339 earliest-dispatch time for a deferred entry. While `now`\nis before this instant the entry is pending-but-not-leasable.\n`None` for ordinary (dispatch-ASAP) entries.",
56+
"type": [
57+
"string",
58+
"null"
59+
]
60+
},
4561
"status": {
4662
"description": "Status value from [`status`].",
4763
"type": "string"

0 commit comments

Comments
 (0)