djogi 0.1.0-alpha.3

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Outbox worker primitives — Phase 5 Task 11.5.
//!
//! These functions implement the consumer side of the transactional outbox
//! pattern. They are gated on the `outbox` cargo feature so adopters who only
//! write to the outbox (via `#[model(events)]`) do not pull in this module.
//!
//! # State machine
//!
//! ```text
//! pending ──claim_pending──► processing ──mark_published──► published
//!//!                                ├──mark_failed(retryable=true, retry_count < MAX_RETRY)──► pending
//!                                └──mark_failed(retryable=false OR retry_count >= MAX_RETRY)──► failed
//!
//! processing ──recover_stale (lease expired)──► pending
//! ```
//!
//! # Retry budget
//!
//! `MAX_RETRY_COUNT = 10` — if a retryable failure occurs ten or more times the
//! row transitions to `failed` terminally. This is a hard-coded constant rather
//! than a deployment configuration because retry budgets belong in the operator's
//! monitoring + alerting stack, not the framework. If the adopter needs a custom
//! budget they can wrap `mark_failed` in their own relay logic.
//!
//! # Table name validation
//!
//! Every function that accepts an `outbox_table` argument validates it before
//! embedding it in SQL. A valid identifier is: an ASCII letter or underscore as
//! the first byte, followed by ASCII alphanumerics or underscores, up to 63
//! bytes total. Anything that fails this check returns
//! `DjogiError::Db(DbError::other("invalid outbox table name: ..."))`.
//!
//! Validation is done with byte-level checks — no regex engine is used or
//! permitted anywhere in this codebase.

use crate::context::DjogiContext;
use crate::error::DbError;
use crate::{DjogiError, HeerId};
use time::OffsetDateTime;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Maximum number of retryable failures before a row becomes terminally failed.
///
/// Chosen as a round number large enough to absorb transient downstream
/// hiccups without letting poison-pill rows spin forever. Deployments that need
/// more or fewer retries should wrap `mark_failed` in their own relay loop that
/// provides a custom budget.
pub const MAX_RETRY_COUNT: i32 = 10;

// ---------------------------------------------------------------------------
// OutboxRow
// ---------------------------------------------------------------------------

/// A claimed outbox row, returned by [`claim_pending`].
///
/// The fields correspond to the normative `worker_outbox` schema columns. The
/// `id` here is the outbox row's own primary key (not the application row's PK);
/// use `row_id` to correlate with the source table.
#[derive(Debug, Clone)]
pub struct OutboxRow {
    /// Primary key of the outbox row itself. Used by `mark_published` /
    /// `mark_failed` to target the exact row.
    pub id: HeerId,

    /// Primary key of the source application row that triggered this event,
    /// decoded as text so non-BIGINT source primary keys can flow through the
    /// worker without losing type information in the database.
    pub row_id: String,

    /// The CRUD action that produced this outbox entry. Matches the
    /// `OutboxAction::as_sql_str()` values: `"create"`, `"save"`, or `"delete"`.
    pub action: String,

    /// Full JSONB payload — the serialised source row (after `outbox_exclude`
    /// filtering at write time).
    pub payload: serde_json::Value,

    /// Wall-clock time the outbox row was inserted, in UTC.
    pub created_at: OffsetDateTime,
}

// ---------------------------------------------------------------------------
// Identifier validation
// ---------------------------------------------------------------------------

/// Validate that `name` is safe to embed as an unquoted SQL identifier.
///
/// Validates the Postgres unquoted-identifier contract for the outbox
/// table name. Routes through [`crate::ident::check_user_supplied_ident`]
/// so the rules stay in lock-step with every other runtime ident check
/// AND so a caller cannot smuggle a `__djogi_*` framework-reserved
/// table name through the worker SQL embedding.
fn validate_table_ident(name: &str) -> Result<(), DjogiError> {
    use crate::ident::IdentError;
    crate::ident::check_user_supplied_ident(name, false).map_err(|e| {
        let msg = match e {
            IdentError::Empty | IdentError::TooLong { .. } => {
                format!("invalid outbox table name {name:?}: must be 1–63 bytes")
            }
            IdentError::BadFirst { .. } => format!(
                "invalid outbox table name {name:?}: first character must be an ASCII letter or underscore"
            ),
            IdentError::BadByte { byte, .. } => format!(
                "invalid outbox table name {name:?}: contains disallowed character '{}'",
                byte as char
            ),
            IdentError::Reserved => unreachable!("check_user_supplied_ident(reserved=false) cannot return Reserved"),
            IdentError::ReservedDjogiPrefix => format!(
                "invalid outbox table name {name:?}: starts with the framework-reserved \
                 `__djogi_` prefix; choose a different name"
            ),
        };
        DjogiError::Db(DbError::other(msg))
    })
}

// ---------------------------------------------------------------------------
// Worker primitives
// ---------------------------------------------------------------------------

/// Atomically claim up to `batch_size` pending outbox rows and return them.
///
/// Uses `FOR UPDATE SKIP LOCKED` inside a sub-`SELECT` so multiple concurrent
/// worker processes claim disjoint batches without coordination overhead.
/// Each claimed row transitions `pending → processing` and its `leased_until`
/// is set to `now() + lease_duration`, giving the caller a window in which to
/// publish before [`recover_stale`] can reclaim the row.
///
/// The claim is atomic: if the enclosing transaction rolls back, the rows
/// remain `pending`. Callers should therefore wrap their claim + publish cycle
/// in `atomic()` so a publish failure or crash does not leave orphaned
/// `processing` rows.
///
/// # Parameters
///
/// - `outbox_table` — the bare table name (e.g. `"worker_outbox"`). Validated
///   before SQL embedding; returns an error if invalid.
/// - `batch_size` — maximum number of rows to claim in one call.
/// - `lease_duration` — how long the claimed rows are locked. Pass a value
///   long enough to cover the publish round-trip plus reasonable retry padding.
pub async fn claim_pending(
    ctx: &mut DjogiContext,
    outbox_table: &str,
    batch_size: u32,
    lease_duration: time::Duration,
) -> Result<Vec<OutboxRow>, DjogiError> {
    validate_table_ident(outbox_table)?;

    // `time::Duration` does not implement `postgres_types::ToSql` directly
    // (the `with-time-0_3` feature covers timestamps, not intervals). We embed
    // the whole-second count directly into the SQL string as an integer literal
    // so Postgres can parse it at planning time without any bind-parameter type
    // ambiguity. The value comes from `whole_seconds()` on a validated
    // `time::Duration`, not from user input, so direct embedding is safe.
    // `leased_until IS NULL OR leased_until <= now()` gates pending rows that
    // are still inside a retry backoff window (see `mark_failed`). Freshly
    // inserted rows have `leased_until = NULL` and claim immediately; rows that
    // were re-queued after a retryable failure have a `leased_until` in the
    // future that encodes their next-attempt time.
    let lease_secs = lease_duration.whole_seconds();
    let sql = format!(
        "UPDATE {outbox_table} \
         SET state = 'processing', leased_until = now() + make_interval(secs => {lease_secs}) \
         WHERE id IN ( \
            SELECT id FROM {outbox_table} \
            WHERE state = 'pending' \
              AND (leased_until IS NULL OR leased_until <= now()) \
            ORDER BY created_at \
            LIMIT $1 \
            FOR UPDATE SKIP LOCKED \
         ) \
         RETURNING id, row_id::text, action, payload, created_at"
    );

    let batch_size_i64 = batch_size as i64;
    let params: &[&(dyn postgres_types::ToSql + Sync)] = &[&batch_size_i64];
    let rows = ctx.query_all(&sql, params).await?;

    let mut result = Vec::with_capacity(rows.len());
    for row in rows {
        let id_raw: i64 = row.try_get(0).map_err(|e| {
            DjogiError::Db(DbError::other(format!("claim_pending: decode id: {e}")))
        })?;
        let row_id: String = row.try_get(1).map_err(|e| {
            DjogiError::Db(DbError::other(format!("claim_pending: decode row_id: {e}")))
        })?;
        let action: String = row.try_get(2).map_err(|e| {
            DjogiError::Db(DbError::other(format!("claim_pending: decode action: {e}")))
        })?;
        let payload: serde_json::Value = row.try_get(3).map_err(|e| {
            DjogiError::Db(DbError::other(format!(
                "claim_pending: decode payload: {e}"
            )))
        })?;
        let created_at: OffsetDateTime = row.try_get(4).map_err(|e| {
            DjogiError::Db(DbError::other(format!(
                "claim_pending: decode created_at: {e}"
            )))
        })?;

        let id = HeerId::from_i64(id_raw).map_err(|e| {
            DjogiError::Db(DbError::other(format!(
                "claim_pending: invalid HeerId value {id_raw}: {e}"
            )))
        })?;

        result.push(OutboxRow {
            id,
            row_id,
            action,
            payload,
            created_at,
        });
    }

    Ok(result)
}

/// Transition an outbox row from `processing` to `published` (terminal success).
///
/// After this call the row will never be claimed again. Callers should invoke
/// this only after the downstream publisher has confirmed delivery.
///
/// `row_id` here is the `id` column of the **outbox** row (the value in
/// `OutboxRow::id`), not the source application row's PK.
pub async fn mark_published(
    ctx: &mut DjogiContext,
    outbox_table: &str,
    row_id: HeerId,
) -> Result<(), DjogiError> {
    validate_table_ident(outbox_table)?;

    let sql = format!(
        "UPDATE {outbox_table} \
         SET state = 'published' \
         WHERE id = $1 AND state = 'processing'"
    );

    let id_raw = row_id.as_i64();
    let params: &[&(dyn postgres_types::ToSql + Sync)] = &[&id_raw];
    ctx.execute(&sql, params).await?;
    Ok(())
}

/// Transition an outbox row from `processing` based on publish outcome.
///
/// - **Retryable + below budget**: row moves back to `pending`, `retry_count`
///   increments by one, and `leased_until` is cleared. The next
///   [`claim_pending`] call will pick it up again.
/// - **Non-retryable OR budget exhausted** (`retry_count >= MAX_RETRY_COUNT`):
///   row transitions to `failed` terminally with `failed_reason` set to
///   `error_message`.
///
/// `row_id` is the outbox row's own `id` (from `OutboxRow::id`).
pub async fn mark_failed(
    ctx: &mut DjogiContext,
    outbox_table: &str,
    row_id: HeerId,
    error_message: &str,
    retryable: bool,
) -> Result<(), DjogiError> {
    validate_table_ident(outbox_table)?;

    // First, fetch current retry_count so we can decide which branch to take.
    // We do this as a separate query rather than a single conditional UPDATE to
    // keep the SQL readable. The row is still in state='processing' so no
    // other worker can claim it during this window (they skip 'processing').
    let fetch_sql =
        format!("SELECT retry_count FROM {outbox_table} WHERE id = $1 AND state = 'processing'");
    let id_raw = row_id.as_i64();
    let fetch_params: &[&(dyn postgres_types::ToSql + Sync)] = &[&id_raw];
    let maybe_row = ctx.query_opt(&fetch_sql, fetch_params).await?;

    let current_retry: i32 = match maybe_row {
        Some(row) => row.try_get(0).map_err(|e| {
            DjogiError::Db(DbError::other(format!(
                "mark_failed: decode retry_count: {e}"
            )))
        })?,
        None => {
            // Row not found or not in 'processing' state — nothing to do.
            return Ok(());
        }
    };

    let transition_to_pending = retryable && current_retry < MAX_RETRY_COUNT;

    if transition_to_pending {
        // Retryable and within budget: return to pending, increment counter, and
        // schedule the next attempt with exponential backoff.
        //
        // The backoff reuses `leased_until` as a "not claimable before" gate on
        // pending rows (see `claim_pending`, which filters out pending rows whose
        // `leased_until` is still in the future). This avoids a schema change.
        //
        // Delay = 2^new_retry seconds, capped at 1024 (≈17 min). Base-2 doubling
        // starting at 2s for the first retry, 4s, 8s, … up to the cap at
        // retry_count = 10. Poison-pill rows hit the retry budget before the cap
        // becomes relevant in practice.
        let new_retry = current_retry.saturating_add(1);
        let shift = new_retry.clamp(0, 10) as u32;
        let backoff_secs: i64 = (1i64 << shift).min(1024);

        let sql = format!(
            "UPDATE {outbox_table} \
             SET state = 'pending', retry_count = retry_count + 1, \
                 leased_until = now() + make_interval(secs => {backoff_secs}), \
                 failed_reason = $2 \
             WHERE id = $1 AND state = 'processing'"
        );
        let params: &[&(dyn postgres_types::ToSql + Sync)] = &[&id_raw, &error_message];
        ctx.execute(&sql, params).await?;
    } else {
        // Non-retryable or budget exhausted: mark terminal failure.
        let sql = format!(
            "UPDATE {outbox_table} \
             SET state = 'failed', failed_reason = $2 \
             WHERE id = $1 AND state = 'processing'"
        );
        let params: &[&(dyn postgres_types::ToSql + Sync)] = &[&id_raw, &error_message];
        ctx.execute(&sql, params).await?;
    }

    Ok(())
}

/// Reset stale `processing` rows back to `pending` so they can be reclaimed.
///
/// A row is considered stale when its `leased_until` timestamp is more than
/// `stale_threshold` seconds in the past — i.e. the worker that claimed it
/// failed to call `mark_published` or `mark_failed` within the lease window
/// and the caller-supplied grace threshold has also elapsed. The grace window
/// avoids racing healthy workers whose lease has only just expired. This is
/// the crash-recovery path.
///
/// Returns the number of rows that were moved back to `pending`.
///
/// # When to call this
///
/// Call `recover_stale` periodically (e.g. once per poll loop iteration) from
/// a separate context — not inside the same atomic scope as `claim_pending`.
/// This ensures that rows abandoned by crashed workers are available to the
/// next healthy claim.
pub async fn recover_stale(
    ctx: &mut DjogiContext,
    outbox_table: &str,
    stale_threshold: time::Duration,
) -> Result<u64, DjogiError> {
    validate_table_ident(outbox_table)?;

    // Same interval-embedding approach as claim_pending: embed the whole-second
    // count directly in the SQL string to avoid bind-parameter type ambiguity
    // with Postgres interval arithmetic.
    let threshold_secs = stale_threshold.whole_seconds();
    let sql = format!(
        "UPDATE {outbox_table} \
         SET state = 'pending', leased_until = NULL \
         WHERE state = 'processing' AND leased_until < now() - make_interval(secs => {threshold_secs})"
    );

    let params: &[&(dyn postgres_types::ToSql + Sync)] = &[];
    let count = ctx.execute(&sql, params).await?;
    Ok(count)
}

// ---------------------------------------------------------------------------
// Unit tests — identifier validation (no DB required)
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_idents_pass() {
        assert!(validate_table_ident("worker_outbox").is_ok());
        assert!(validate_table_ident("accounts_outbox").is_ok());
        assert!(validate_table_ident("orders_outbox_v2").is_ok());
        assert!(validate_table_ident("_private").is_ok());
        assert!(validate_table_ident("a").is_ok());
    }

    #[test]
    fn empty_ident_rejected() {
        assert!(validate_table_ident("").is_err());
    }

    #[test]
    fn ident_over_63_bytes_rejected() {
        // 64 lowercase letters
        let long = "a".repeat(64);
        assert!(validate_table_ident(&long).is_err());
    }

    #[test]
    fn ident_exactly_63_bytes_ok() {
        let ok = "a".repeat(63);
        assert!(validate_table_ident(&ok).is_ok());
    }

    #[test]
    fn digit_first_byte_rejected() {
        assert!(validate_table_ident("1outbox").is_err());
    }

    #[test]
    fn hyphen_rejected() {
        assert!(validate_table_ident("worker-outbox").is_err());
    }

    #[test]
    fn dot_rejected() {
        assert!(validate_table_ident("schema.table").is_err());
    }

    #[test]
    fn space_rejected() {
        assert!(validate_table_ident("worker outbox").is_err());
    }

    #[test]
    fn djogi_reserved_prefix_rejected() {
        // Issue #82 — uniform reservation rule. The outbox worker is
        // an adopter-facing surface (the table name is computed from
        // a user model name in `outbox/mod.rs::emit_event` and could
        // be passed directly here in test fixtures). User-supplied SQL
        // identifier surfaces must not enter the framework-reserved
        // namespace; reject before SQL is composed.
        let err = validate_table_ident("__djogi_outbox").expect_err("must reject reserved prefix");
        let msg = err.to_string();
        assert!(msg.contains("`__djogi_` prefix"), "got: {msg}");
        // Bare prefix is also rejected (no suffix needed).
        assert!(validate_table_ident("__djogi_").is_err());
        // Adopter-derivative reservations stay legal.
        assert!(validate_table_ident("__myadopter_outbox").is_ok());
        // Single-underscore variants stay legal — the rule is the
        // exact double-underscore prefix.
        assert!(validate_table_ident("_djogi_outbox").is_ok());
    }
}