1use serde_json::Value;
4use std::fmt;
5
6use crate::{Error, FromRow, Orm};
7
8const MAX_KEY_LEN: usize = 128;
9const MAX_PAYLOAD_BYTES: usize = 1_048_576;
10const MAX_ERROR_LEN: usize = 512;
11
12#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct EnqueuedOutboxEvent {
15 pub id: i64,
17 pub inserted: bool,
19}
20
21#[derive(Clone, FromRow)]
23pub struct ClaimedOutboxEvent {
24 pub id: i64,
26 pub stream: String,
28 pub event_key: String,
30 pub event_kind: String,
32 pub payload_json: String,
34 pub attempts: i32,
36 pub claim_key: String,
38 pub claim_expires_at_epoch: i64,
40}
41
42impl fmt::Debug for ClaimedOutboxEvent {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 formatter
45 .debug_struct("ClaimedOutboxEvent")
46 .field("id", &self.id)
47 .field("stream", &"[REDACTED]")
48 .field("event_key", &"[REDACTED]")
49 .field("event_kind", &self.event_kind)
50 .field("payload_bytes", &self.payload_json.len())
51 .field("attempts", &self.attempts)
52 .field("claim_key", &"[REDACTED]")
53 .field("claim_expires_at_epoch", &self.claim_expires_at_epoch)
54 .finish()
55 }
56}
57
58impl ClaimedOutboxEvent {
59 pub fn payload(&self) -> Result<Value, Error> {
61 serde_json::from_str(&self.payload_json).map_err(Into::into)
62 }
63}
64
65pub struct Outbox;
67
68pub struct OutboxMigration;
70
71#[async_trait::async_trait]
72impl crate::schema::migration::Migration for OutboxMigration {
73 fn name(&self) -> &'static str {
74 "m20260830_000001_create_rullst_outbox"
75 }
76
77 async fn up(&self) -> Result<(), Error> {
78 Outbox::install().await
79 }
80
81 async fn down(&self) -> Result<(), Error> {
82 sqlx::query("DROP TABLE IF EXISTS rullst_outbox")
83 .execute(Orm::pool()?)
84 .await?;
85 Ok(())
86 }
87}
88
89impl Outbox {
90 pub async fn install() -> Result<(), Error> {
101 let driver = Orm::driver()?;
102 let table_sql = match driver {
103 "postgres" => POSTGRES_TABLE,
104 "mysql" => MYSQL_TABLE,
105 _ => SQLITE_TABLE,
106 };
107 sqlx::query(table_sql).execute(Orm::pool()?).await?;
108 if driver == "mysql" {
109 let (case_insensitive_keys,): (i64,) = sqlx::query_as(MYSQL_KEY_COLLATION_CHECK)
110 .fetch_one(Orm::pool()?)
111 .await?;
112 if case_insensitive_keys > 0 {
113 sqlx::query(MYSQL_KEY_COLLATION_UPGRADE)
114 .execute(Orm::pool()?)
115 .await?;
116 }
117 } else {
118 sqlx::query(
119 "CREATE INDEX IF NOT EXISTS rullst_outbox_delivery_idx \
120 ON rullst_outbox (stream, status, available_at_epoch, claim_expires_at_epoch, id)",
121 )
122 .execute(Orm::pool()?)
123 .await?;
124 }
125 Ok(())
126 }
127
128 pub async fn enqueue(
133 stream: impl Into<String>,
134 event_key: impl Into<String>,
135 event_kind: impl Into<String>,
136 payload: &Value,
137 ) -> Result<EnqueuedOutboxEvent, Error> {
138 crate::__transaction_access::ensure_allowed()?;
139 let stream = stream.into();
140 let event_key = event_key.into();
141 let event_kind = event_kind.into();
142 let transaction = crate::CURRENT_TX
143 .try_with(Clone::clone)
144 .map_err(|_| missing_transaction_error())?;
145 let mut transaction = transaction.lock().await;
146 let transaction = transaction.as_mut().ok_or_else(missing_transaction_error)?;
147 Self::enqueue_with_tx_inner(transaction, &stream, &event_key, &event_kind, payload).await
148 }
149
150 pub async fn enqueue_with_tx(
152 transaction: &mut crate::db::Transaction<'_>,
153 stream: impl Into<String>,
154 event_key: impl Into<String>,
155 event_kind: impl Into<String>,
156 payload: &Value,
157 ) -> Result<EnqueuedOutboxEvent, Error> {
158 let stream = stream.into();
159 let event_key = event_key.into();
160 let event_kind = event_kind.into();
161 Self::enqueue_with_tx_inner(transaction, &stream, &event_key, &event_kind, payload).await
162 }
163
164 async fn enqueue_with_tx_inner(
165 transaction: &mut crate::db::Transaction<'_>,
166 stream: &str,
167 event_key: &str,
168 event_kind: &str,
169 payload: &Value,
170 ) -> Result<EnqueuedOutboxEvent, Error> {
171 validate_key("stream", stream)?;
172 validate_key("event_key", event_key)?;
173 validate_key("event_kind", event_kind)?;
174 let payload_json = serde_json::to_string(payload)?;
175 if payload_json.len() > MAX_PAYLOAD_BYTES {
176 return Err(Error::Validation(format!(
177 "outbox payload exceeds {MAX_PAYLOAD_BYTES} bytes"
178 )));
179 }
180 let now = unix_now()?;
181 let insert_token = uuid::Uuid::new_v4().simple().to_string();
182 let driver = Orm::driver()?;
183 let insert_sql = match driver {
184 "postgres" => POSTGRES_INSERT,
185 "mysql" => MYSQL_INSERT,
186 _ => SQLITE_INSERT,
187 };
188 sqlx::query(insert_sql)
189 .bind(stream)
190 .bind(event_key)
191 .bind(event_kind)
192 .bind(&payload_json)
193 .bind("pending")
194 .bind(0_i32)
195 .bind("")
196 .bind(0_i64)
197 .bind("")
198 .bind(now)
199 .bind(now)
200 .bind(&insert_token)
201 .execute(&mut **transaction)
202 .await?;
203 let select_sql = match driver {
204 "postgres" => POSTGRES_ENQUEUED_SELECT,
205 "mysql" => MYSQL_ENQUEUED_SELECT,
206 _ => SQLITE_ENQUEUED_SELECT,
207 };
208 let (id, stored_kind, stored_payload, stored_insert_token) =
209 sqlx::query_as::<_, (i64, String, String, String)>(select_sql)
210 .bind(stream)
211 .bind(event_key)
212 .fetch_optional(&mut **transaction)
213 .await?
214 .ok_or_else(|| {
215 Error::DatabaseError(
216 "outbox event could not be read back after its idempotent insert"
217 .to_string(),
218 )
219 })?;
220 if stored_kind != event_kind || stored_payload != payload_json {
221 return Err(Error::Validation(format!(
222 "outbox idempotency key '{event_key}' already exists in stream '{stream}' with different content"
223 )));
224 }
225 let inserted = stored_insert_token == insert_token;
226 Ok(EnqueuedOutboxEvent { id, inserted })
227 }
228
229 pub async fn claim_next(
231 stream: impl Into<String>,
232 worker_id: impl Into<String>,
233 lease_seconds: i64,
234 max_attempts: i32,
235 ) -> Result<Option<ClaimedOutboxEvent>, Error> {
236 let stream = stream.into();
237 let worker_id = worker_id.into();
238 Self::claim_next_at_inner(
239 &stream,
240 &worker_id,
241 unix_now()?,
242 lease_seconds,
243 max_attempts,
244 )
245 .await
246 }
247
248 #[doc(hidden)]
249 pub async fn claim_next_at(
250 stream: impl Into<String>,
251 worker_id: impl Into<String>,
252 now_epoch_seconds: i64,
253 lease_seconds: i64,
254 max_attempts: i32,
255 ) -> Result<Option<ClaimedOutboxEvent>, Error> {
256 let stream = stream.into();
257 let worker_id = worker_id.into();
258 Self::claim_next_at_inner(
259 &stream,
260 &worker_id,
261 now_epoch_seconds,
262 lease_seconds,
263 max_attempts,
264 )
265 .await
266 }
267
268 async fn claim_next_at_inner(
269 stream: &str,
270 worker_id: &str,
271 now_epoch_seconds: i64,
272 lease_seconds: i64,
273 max_attempts: i32,
274 ) -> Result<Option<ClaimedOutboxEvent>, Error> {
275 validate_key("stream", stream)?;
276 validate_key("worker_id", worker_id)?;
277 if now_epoch_seconds <= 0
278 || !(1..=3_600).contains(&lease_seconds)
279 || !(1..=100).contains(&max_attempts)
280 {
281 return Err(Error::Validation(
282 "outbox claim timestamp, lease or attempt limit is outside its bound".to_string(),
283 ));
284 }
285 let claim_expires_at_epoch = now_epoch_seconds
286 .checked_add(lease_seconds)
287 .ok_or_else(|| Error::Validation("outbox claim lease overflowed".to_string()))?;
288 let claim_key = uuid::Uuid::new_v4().simple().to_string();
289 let driver = Orm::driver()?;
290 let mut transaction = Orm::begin_transaction().await?;
291 let exhaust_sql = if driver == "postgres" {
292 POSTGRES_EXHAUST
293 } else {
294 PORTABLE_EXHAUST
295 };
296 sqlx::query(exhaust_sql)
297 .bind("dead_letter")
298 .bind("")
299 .bind("")
300 .bind(0_i64)
301 .bind("claim attempt limit reached")
302 .bind(stream)
303 .bind(max_attempts)
304 .bind("pending")
305 .bind("processing")
306 .bind(now_epoch_seconds)
307 .execute(&mut *transaction)
308 .await?;
309
310 let select_sql = match driver {
311 "postgres" => POSTGRES_CLAIM_SELECT,
312 "mysql" => MYSQL_CLAIM_SELECT,
313 _ => PORTABLE_CLAIM_SELECT,
314 };
315 let candidate = sqlx::query_scalar::<_, i64>(select_sql)
316 .bind(stream)
317 .bind("pending")
318 .bind(now_epoch_seconds)
319 .bind("processing")
320 .bind(now_epoch_seconds)
321 .bind(max_attempts)
322 .fetch_optional(&mut *transaction)
323 .await?;
324 let Some(id) = candidate else {
325 transaction.commit().await?;
326 return Ok(None);
327 };
328
329 let update_sql = if driver == "postgres" {
330 POSTGRES_CLAIM_UPDATE
331 } else {
332 PORTABLE_CLAIM_UPDATE
333 };
334 let claimed = sqlx::query(update_sql)
335 .bind("processing")
336 .bind(worker_id)
337 .bind(&claim_key)
338 .bind(claim_expires_at_epoch)
339 .bind("")
340 .bind(id)
341 .bind(stream)
342 .bind("pending")
343 .bind(now_epoch_seconds)
344 .bind("processing")
345 .bind(now_epoch_seconds)
346 .bind(max_attempts)
347 .execute(&mut *transaction)
348 .await?
349 .rows_affected()
350 == 1;
351 if !claimed {
352 transaction.rollback().await?;
353 return Ok(None);
354 }
355
356 let fetch_sql = if driver == "postgres" {
357 POSTGRES_CLAIM_FETCH
358 } else {
359 PORTABLE_CLAIM_FETCH
360 };
361 let event = sqlx::query_as::<_, ClaimedOutboxEvent>(fetch_sql)
362 .bind(id)
363 .bind(stream)
364 .bind("processing")
365 .bind(&claim_key)
366 .fetch_one(&mut *transaction)
367 .await?;
368 transaction.commit().await?;
369 Ok(Some(event))
370 }
371
372 pub async fn acknowledge(id: i64, claim_key: impl Into<String>) -> Result<bool, Error> {
374 let claim_key = claim_key.into();
375 transition(id, &claim_key, Transition::Delivered).await
376 }
377
378 pub async fn fail(
380 id: i64,
381 claim_key: impl Into<String>,
382 error: impl Into<String>,
383 max_attempts: i32,
384 retry_delay_seconds: i64,
385 ) -> Result<bool, Error> {
386 let claim_key = claim_key.into();
387 let error = error.into();
388 let retry_at_epoch = unix_now()?
389 .checked_add(retry_delay_seconds)
390 .ok_or_else(|| Error::Validation("outbox retry timestamp overflowed".to_string()))?;
391 transition(
392 id,
393 &claim_key,
394 Transition::Failed {
395 error: &error,
396 max_attempts,
397 retry_at_epoch,
398 retry_delay_seconds,
399 },
400 )
401 .await
402 }
403}
404
405enum Transition<'a> {
406 Delivered,
407 Failed {
408 error: &'a str,
409 max_attempts: i32,
410 retry_at_epoch: i64,
411 retry_delay_seconds: i64,
412 },
413}
414
415async fn transition(id: i64, claim_key: &str, transition: Transition<'_>) -> Result<bool, Error> {
416 if id <= 0 {
417 return Err(Error::Validation("outbox id must be positive".to_string()));
418 }
419 validate_key("claim_key", claim_key)?;
420 let driver = Orm::driver()?;
421 let now_epoch_seconds = unix_now()?;
422 let result = match transition {
423 Transition::Delivered => {
424 let sql = if driver == "postgres" {
425 POSTGRES_ACK
426 } else {
427 PORTABLE_ACK
428 };
429 sqlx::query(sql)
430 .bind("delivered")
431 .bind("")
432 .bind("")
433 .bind(0_i64)
434 .bind(now_epoch_seconds)
435 .bind(id)
436 .bind("processing")
437 .bind(claim_key)
438 .bind(now_epoch_seconds)
439 .execute(Orm::pool()?)
440 .await?
441 }
442 Transition::Failed {
443 error,
444 max_attempts,
445 retry_at_epoch,
446 retry_delay_seconds,
447 } => {
448 if !(1..=100).contains(&max_attempts)
449 || !(0..=86_400).contains(&retry_delay_seconds)
450 || error.is_empty()
451 || error.len() > MAX_ERROR_LEN
452 || error.chars().any(char::is_control)
453 {
454 return Err(Error::Validation(
455 "outbox failure policy is outside its bound".to_string(),
456 ));
457 }
458 let sql = if driver == "postgres" {
459 POSTGRES_FAIL
460 } else {
461 PORTABLE_FAIL
462 };
463 sqlx::query(sql)
464 .bind(max_attempts)
465 .bind("dead_letter")
466 .bind("pending")
467 .bind(retry_at_epoch)
468 .bind("")
469 .bind("")
470 .bind(0_i64)
471 .bind(error)
472 .bind(id)
473 .bind("processing")
474 .bind(claim_key)
475 .bind(now_epoch_seconds)
476 .execute(Orm::pool()?)
477 .await?
478 }
479 };
480 Ok(result.rows_affected() == 1)
481}
482
483fn validate_key(field: &str, value: &str) -> Result<(), Error> {
484 if value.is_empty()
485 || value.len() > MAX_KEY_LEN
486 || !value
487 .bytes()
488 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.'))
489 {
490 return Err(Error::Validation(format!(
491 "outbox {field} must contain 1-{MAX_KEY_LEN} ASCII letters, digits, '.', '-', '_' or ':'"
492 )));
493 }
494 Ok(())
495}
496
497fn unix_now() -> Result<i64, Error> {
498 let elapsed = std::time::SystemTime::now()
499 .duration_since(std::time::UNIX_EPOCH)
500 .map_err(|_| Error::Internal("system clock is before the Unix epoch".to_string()))?;
501 i64::try_from(elapsed.as_secs())
502 .map_err(|_| Error::Internal("Unix timestamp exceeds i64".to_string()))
503}
504
505fn missing_transaction_error() -> Error {
506 Error::Validation(
507 "Outbox::enqueue must run inside Orm::transaction; use enqueue_with_tx for a caller-owned transaction"
508 .to_string(),
509 )
510}
511
512const POSTGRES_TABLE: &str = "CREATE TABLE IF NOT EXISTS rullst_outbox (id BIGSERIAL PRIMARY KEY, stream VARCHAR(128) NOT NULL, event_key VARCHAR(128) NOT NULL, event_kind VARCHAR(128) NOT NULL, payload_json TEXT NOT NULL, status VARCHAR(16) NOT NULL, attempts INTEGER NOT NULL, claimed_by VARCHAR(128) NOT NULL, claim_key VARCHAR(128) NOT NULL, claim_expires_at_epoch BIGINT NOT NULL, last_error VARCHAR(512) NOT NULL, available_at_epoch BIGINT NOT NULL, created_at_epoch BIGINT NOT NULL, delivered_at_epoch BIGINT, insert_token VARCHAR(32) NOT NULL, UNIQUE (stream, event_key))";
513const MYSQL_TABLE: &str = "CREATE TABLE IF NOT EXISTS rullst_outbox (id BIGINT AUTO_INCREMENT PRIMARY KEY, stream VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, event_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, event_kind VARCHAR(128) NOT NULL, payload_json LONGTEXT NOT NULL, status VARCHAR(16) NOT NULL, attempts INT NOT NULL, claimed_by VARCHAR(128) NOT NULL, claim_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, claim_expires_at_epoch BIGINT NOT NULL, last_error VARCHAR(512) NOT NULL, available_at_epoch BIGINT NOT NULL, created_at_epoch BIGINT NOT NULL, delivered_at_epoch BIGINT NULL, insert_token VARCHAR(32) NOT NULL, UNIQUE KEY rullst_outbox_stream_event_unique (stream, event_key), INDEX rullst_outbox_delivery_idx (stream, status, available_at_epoch, claim_expires_at_epoch, id))";
515const MYSQL_KEY_COLLATION_CHECK: &str = "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rullst_outbox' AND COLUMN_NAME IN ('stream', 'event_key', 'claim_key') AND (COLLATION_NAME IS NULL OR COLLATION_NAME <> 'ascii_bin')";
516const MYSQL_KEY_COLLATION_UPGRADE: &str = "ALTER TABLE rullst_outbox MODIFY stream VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, MODIFY event_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, MODIFY claim_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL";
517const SQLITE_TABLE: &str = "CREATE TABLE IF NOT EXISTS rullst_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, stream TEXT NOT NULL, event_key TEXT NOT NULL, event_kind TEXT NOT NULL, payload_json TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL, claimed_by TEXT NOT NULL, claim_key TEXT NOT NULL, claim_expires_at_epoch BIGINT NOT NULL, last_error TEXT NOT NULL, available_at_epoch BIGINT NOT NULL, created_at_epoch BIGINT NOT NULL, delivered_at_epoch BIGINT, insert_token TEXT NOT NULL, UNIQUE (stream, event_key))";
518
519const POSTGRES_INSERT: &str = "INSERT INTO rullst_outbox (stream, event_key, event_kind, payload_json, status, attempts, claimed_by, claim_expires_at_epoch, last_error, available_at_epoch, created_at_epoch, insert_token, claim_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, '') ON CONFLICT (stream, event_key) DO NOTHING";
520const MYSQL_INSERT: &str = "INSERT INTO rullst_outbox (stream, event_key, event_kind, payload_json, status, attempts, claimed_by, claim_expires_at_epoch, last_error, available_at_epoch, created_at_epoch, insert_token, claim_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '') ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)";
521const SQLITE_INSERT: &str = "INSERT INTO rullst_outbox (stream, event_key, event_kind, payload_json, status, attempts, claimed_by, claim_expires_at_epoch, last_error, available_at_epoch, created_at_epoch, insert_token, claim_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '') ON CONFLICT (stream, event_key) DO NOTHING";
522
523const POSTGRES_ENQUEUED_SELECT: &str = "SELECT id, event_kind, payload_json, insert_token FROM rullst_outbox WHERE stream = $1 AND event_key = $2";
524const MYSQL_ENQUEUED_SELECT: &str = "SELECT id, event_kind, payload_json, insert_token FROM rullst_outbox WHERE stream = ? AND event_key = ? FOR UPDATE";
529const SQLITE_ENQUEUED_SELECT: &str = "SELECT id, event_kind, payload_json, insert_token FROM rullst_outbox WHERE stream = ? AND event_key = ?";
530
531const POSTGRES_EXHAUST: &str = "UPDATE rullst_outbox SET status = $1, claimed_by = $2, claim_key = $3, claim_expires_at_epoch = $4, last_error = $5 WHERE stream = $6 AND attempts >= $7 AND (status = $8 OR (status = $9 AND claim_expires_at_epoch <= $10))";
532const PORTABLE_EXHAUST: &str = "UPDATE rullst_outbox SET status = ?, claimed_by = ?, claim_key = ?, claim_expires_at_epoch = ?, last_error = ? WHERE stream = ? AND attempts >= ? AND (status = ? OR (status = ? AND claim_expires_at_epoch <= ?))";
533const POSTGRES_CLAIM_SELECT: &str = "SELECT id FROM rullst_outbox WHERE stream = $1 AND ((status = $2 AND available_at_epoch <= $3) OR (status = $4 AND claim_expires_at_epoch <= $5)) AND attempts < $6 ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED";
534const MYSQL_CLAIM_SELECT: &str = "SELECT id FROM rullst_outbox WHERE stream = ? AND ((status = ? AND available_at_epoch <= ?) OR (status = ? AND claim_expires_at_epoch <= ?)) AND attempts < ? ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED";
535const PORTABLE_CLAIM_SELECT: &str = "SELECT id FROM rullst_outbox WHERE stream = ? AND ((status = ? AND available_at_epoch <= ?) OR (status = ? AND claim_expires_at_epoch <= ?)) AND attempts < ? ORDER BY id ASC LIMIT 1";
536const POSTGRES_CLAIM_UPDATE: &str = "UPDATE rullst_outbox SET status = $1, attempts = attempts + 1, claimed_by = $2, claim_key = $3, claim_expires_at_epoch = $4, last_error = $5 WHERE id = $6 AND stream = $7 AND ((status = $8 AND available_at_epoch <= $9) OR (status = $10 AND claim_expires_at_epoch <= $11)) AND attempts < $12";
537const PORTABLE_CLAIM_UPDATE: &str = "UPDATE rullst_outbox SET status = ?, attempts = attempts + 1, claimed_by = ?, claim_key = ?, claim_expires_at_epoch = ?, last_error = ? WHERE id = ? AND stream = ? AND ((status = ? AND available_at_epoch <= ?) OR (status = ? AND claim_expires_at_epoch <= ?)) AND attempts < ?";
538const POSTGRES_CLAIM_FETCH: &str = "SELECT id, stream, event_key, event_kind, payload_json, attempts, claim_key, claim_expires_at_epoch FROM rullst_outbox WHERE id = $1 AND stream = $2 AND status = $3 AND claim_key = $4";
539const PORTABLE_CLAIM_FETCH: &str = "SELECT id, stream, event_key, event_kind, payload_json, attempts, claim_key, claim_expires_at_epoch FROM rullst_outbox WHERE id = ? AND stream = ? AND status = ? AND claim_key = ?";
540const POSTGRES_ACK: &str = "UPDATE rullst_outbox SET status = $1, claimed_by = $2, claim_key = $3, claim_expires_at_epoch = $4, delivered_at_epoch = $5 WHERE id = $6 AND status = $7 AND claim_key = $8 AND claim_expires_at_epoch > $9";
541const PORTABLE_ACK: &str = "UPDATE rullst_outbox SET status = ?, claimed_by = ?, claim_key = ?, claim_expires_at_epoch = ?, delivered_at_epoch = ? WHERE id = ? AND status = ? AND claim_key = ? AND claim_expires_at_epoch > ?";
542const POSTGRES_FAIL: &str = "UPDATE rullst_outbox SET status = CASE WHEN attempts >= $1 THEN $2 ELSE $3 END, available_at_epoch = $4, claimed_by = $5, claim_key = $6, claim_expires_at_epoch = $7, last_error = $8 WHERE id = $9 AND status = $10 AND claim_key = $11 AND claim_expires_at_epoch > $12";
543const PORTABLE_FAIL: &str = "UPDATE rullst_outbox SET status = CASE WHEN attempts >= ? THEN ? ELSE ? END, available_at_epoch = ?, claimed_by = ?, claim_key = ?, claim_expires_at_epoch = ?, last_error = ? WHERE id = ? AND status = ? AND claim_key = ? AND claim_expires_at_epoch > ?";
544
545#[cfg(test)]
546mod tests {
547 use super::{MYSQL_KEY_COLLATION_UPGRADE, MYSQL_TABLE};
548
549 #[test]
550 fn mysql_outbox_keys_compare_case_sensitively() {
551 for column in ["stream", "event_key", "claim_key"] {
552 let declaration =
553 format!("{column} VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL");
554 assert!(MYSQL_TABLE.contains(&declaration), "{column} table DDL");
555 assert!(
556 MYSQL_KEY_COLLATION_UPGRADE.contains(&format!("MODIFY {declaration}")),
557 "{column} upgrade DDL"
558 );
559 }
560 }
561}