feat(v1.1.9): QueueRepo trait + Postgres impl + shared queue.rs

- shared/queue.rs: QueueService trait (enqueue / depth / depth_pending),
  EnqueueOpts, QueueError, NoopQueueService. Methods derive app_id from
  cx.app_id — no script-passed app_id. The handle-less surface mirrors
  pubsub (queues are the grouping unit).
- shared/ids.rs: QueueMessageId.
- manager-core/queue_repo.rs: PostgresQueueRepo with:
   - enqueue(NewQueueMessage) — single INSERT
   - claim(app_id, queue_name) — atomic UPDATE WHERE id = (SELECT … FOR
     UPDATE SKIP LOCKED LIMIT 1) RETURNING — the single-round-trip claim
     from the design notes
   - ack(id, claim_token) — DELETE WHERE id AND claim_token (lease check)
   - nack(id, claim_token, retry_delay) — clear claim + set deliver_after
   - reclaim_visibility_timeouts() — periodic UPDATE joining triggers +
     queue_trigger_details, clears claims older than per-queue
     visibility_timeout_secs
   - depth / depth_pending / list_for_app (dashboard read-only)
   - dead_letter(...) — atomic move to dead_letters + DELETE from
     queue_messages, in one transaction. Uses a JSON payload shaped the
     same as TriggerEvent::Queue so the existing fan_out_dead_letter
     path delivers the original to registered dead_letter triggers
     without special-casing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 19:12:33 +02:00
parent 9ce3c4c704
commit f6c7ab6f7c
5 changed files with 572 additions and 2 deletions

103
crates/shared/src/queue.rs Normal file
View File

@@ -0,0 +1,103 @@
//! `QueueService` — the v1.1.9 durable queue contract.
//!
//! `queue::enqueue(name, message, opts?)` writes one row to
//! `queue_messages`; the dispatcher's queue arm claims it later via
//! `FOR UPDATE SKIP LOCKED` and fires the registered `queue:receive`
//! trigger (commit 6). On handler success the row is deleted (ack);
//! on throw it's nacked (claim cleared, retry per the trigger's policy
//! with `deliver_after = NOW() + backoff`); when `attempt >= max_attempts`
//! the message is moved to `dead_letters`.
//!
//! No `peek`/`dequeue`/`purge` script-side surface — consumers are
//! triggers. Exposing manual dequeue would force PiCloud to expose
//! visibility-timeout + nack semantics to script-land, which is the
//! whole reason queue is trigger-based.
//!
//! Identity tuple: `(cx.app_id, queue_name)`. Cross-app isolation is
//! enforced because every method derives `app_id` from `cx.app_id` —
//! never from a script argument.
use async_trait::async_trait;
use thiserror::Error;
use crate::{QueueMessageId, SdkCallCx};
/// Optional per-enqueue overrides passed to [`QueueService::enqueue`].
#[derive(Debug, Clone, Copy, Default)]
pub struct EnqueueOpts {
/// Defer delivery until `NOW() + delay_ms`. Default: immediate.
pub delay_ms: Option<i64>,
/// Override the default `max_attempts` (3) for THIS message. Clamped
/// to `[1, 20]` at the SDK boundary so a script can't enqueue a
/// retry-forever message.
pub max_attempts: Option<u32>,
}
#[async_trait]
pub trait QueueService: Send + Sync {
/// Enqueue one message onto `(cx.app_id, queue_name)`. Returns the
/// new message id (forensic; not surfaced to scripts in the
/// script-side return).
async fn enqueue(
&self,
cx: &SdkCallCx,
queue_name: &str,
payload: serde_json::Value,
opts: EnqueueOpts,
) -> Result<QueueMessageId, QueueError>;
/// Total queued messages (claimed + unclaimed + retrying-but-not-deleted)
/// for `(cx.app_id, queue_name)`. Surfaces as `queue::depth(name)`.
async fn depth(&self, cx: &SdkCallCx, queue_name: &str) -> Result<u64, QueueError>;
/// Currently-claimable count — `claim_token IS NULL` AND `deliver_after`
/// has passed. Surfaces as `queue::depth_pending(name)`. Diverges from
/// `depth` for delayed / in-flight messages.
async fn depth_pending(&self, cx: &SdkCallCx, queue_name: &str) -> Result<u64, QueueError>;
}
#[derive(Debug, Error)]
pub enum QueueError {
#[error("queue_name must not be empty")]
EmptyName,
/// Payload could not be serialized (e.g. a Rhai FnPtr / closure
/// captured into the message). Rejected at the SDK boundary; surface
/// the wording verbatim so scripts see the documented message.
#[error("queue rejected: {0}")]
Rejected(String),
/// max_attempts (or delay_ms) outside the allowed bounds.
#[error("{0}")]
InvalidOpts(String),
/// Backend unavailable (Postgres down, etc.).
#[error("queue backend error: {0}")]
Unavailable(String),
}
/// Test-only stub: every call errors `Unavailable`. Used by harnesses
/// that build a `Services` bundle without a database.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopQueueService;
#[async_trait]
impl QueueService for NoopQueueService {
async fn enqueue(
&self,
_cx: &SdkCallCx,
_queue_name: &str,
_payload: serde_json::Value,
_opts: EnqueueOpts,
) -> Result<QueueMessageId, QueueError> {
Err(QueueError::Unavailable("queue is not wired in".into()))
}
async fn depth(&self, _cx: &SdkCallCx, _queue_name: &str) -> Result<u64, QueueError> {
Err(QueueError::Unavailable("queue is not wired in".into()))
}
async fn depth_pending(&self, _cx: &SdkCallCx, _queue_name: &str) -> Result<u64, QueueError> {
Err(QueueError::Unavailable("queue is not wired in".into()))
}
}