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:
@@ -56,3 +56,4 @@ id_type!(ApiKeyId);
|
||||
id_type!(TriggerId);
|
||||
id_type!(AppUserId);
|
||||
id_type!(InvitationId);
|
||||
id_type!(QueueMessageId);
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod log_sink;
|
||||
pub mod modules;
|
||||
pub mod outbox_writer;
|
||||
pub mod pubsub;
|
||||
pub mod queue;
|
||||
pub mod realtime;
|
||||
pub mod realtime_authority;
|
||||
pub mod route;
|
||||
@@ -53,8 +54,8 @@ pub use files::{
|
||||
};
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, RequestId, ScriptId,
|
||||
TriggerId,
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId,
|
||||
ScriptId, TriggerId,
|
||||
};
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
@@ -66,6 +67,7 @@ pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, Outbox
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
};
|
||||
pub use queue::{EnqueueOpts, NoopQueueService, QueueError, QueueService};
|
||||
pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaster, RealtimeEvent};
|
||||
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
|
||||
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||
|
||||
103
crates/shared/src/queue.rs
Normal file
103
crates/shared/src/queue.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user