1use async_trait::async_trait;
2use chrono::{DateTime, TimeDelta, Utc};
3use minco_plugin_audit::{
4 AuditAppendReport, AuditCursor, AuditJournalEntry, AuditJournalStatus, AuditJournalStore,
5 AuditLedgerError, AuditLedgerWriter, AuditLifecyclePolicy, AuditPage, AuditQuery, AuditReader,
6 AuditRecordV2, AuditSegmentState, AuditSegmentStatus, AuditStorageHealth,
7 AuditStorageInspector, AuditStorageSnapshot, evaluate_storage_health,
8};
9use sqlx::{PgPool, Postgres, QueryBuilder, Row, Transaction, postgres::PgRow};
10use std::collections::{BTreeMap, BTreeSet};
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
14pub struct PostgresAuditJournal {
15 pool: PgPool,
16}
17
18impl PostgresAuditJournal {
19 pub const fn new(pool: PgPool) -> Self {
20 Self { pool }
21 }
22
23 pub async fn enqueue_in(
25 &self,
26 transaction: &mut Transaction<'_, Postgres>,
27 entry: AuditJournalEntry,
28 ) -> Result<(), AuditLedgerError> {
29 validate_pending_entry(&entry)?;
30 let record = serde_json::to_value(&entry.record).map_err(|_| AuditLedgerError::Encoding)?;
31 let encoded_bytes = i32::try_from(entry.encoded_bytes)
32 .map_err(|_| AuditLedgerError::InvalidJournalEntry)?;
33 let result = sqlx::query(
34 "INSERT INTO minco_audit_journal
35 (event_id, occurred_at, record, encoded_bytes, status, attempt_count,
36 available_at, claimed_by, claim_expires_at, failure_code)
37 VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8, $9)
38 ON CONFLICT(event_id) DO NOTHING",
39 )
40 .bind(entry.record.event_id)
41 .bind(entry.record.occurred_at)
42 .bind(&record)
43 .bind(encoded_bytes)
44 .bind(
45 i32::try_from(entry.attempt_count)
46 .map_err(|_| AuditLedgerError::InvalidJournalEntry)?,
47 )
48 .bind(entry.available_at)
49 .bind(entry.claimed_by)
50 .bind(entry.claim_expires_at)
51 .bind(entry.failure_code)
52 .execute(&mut **transaction)
53 .await
54 .map_err(infrastructure)?;
55 if result.rows_affected() == 1 {
56 return Ok(());
57 }
58 let existing: serde_json::Value =
59 sqlx::query_scalar("SELECT record FROM minco_audit_journal WHERE event_id = $1")
60 .bind(entry.record.event_id)
61 .fetch_one(&mut **transaction)
62 .await
63 .map_err(infrastructure)?;
64 if existing == record {
65 Ok(())
66 } else {
67 Err(AuditLedgerError::EventConflict(entry.record.event_id))
68 }
69 }
70
71 async fn transition(
72 &self,
73 event_ids: &[Uuid],
74 worker_id: &str,
75 transition: JournalTransition<'_>,
76 ) -> Result<(), AuditLedgerError> {
77 validate_transition(event_ids, worker_id)?;
78 let result = match transition {
79 JournalTransition::Delivered => {
80 sqlx::query(
81 "DELETE FROM minco_audit_journal
82 WHERE event_id = ANY($1) AND status = 'claimed' AND claimed_by = $2",
83 )
84 .bind(event_ids)
85 .bind(worker_id)
86 .execute(&self.pool)
87 .await
88 }
89 JournalTransition::Retry {
90 failure_code,
91 retry_at,
92 } => {
93 validate_failure_code(failure_code)?;
94 sqlx::query(
95 "UPDATE minco_audit_journal
96 SET status = 'failed', available_at = $3, failure_code = $4,
97 claimed_by = NULL, claim_expires_at = NULL
98 WHERE event_id = ANY($1) AND status = 'claimed' AND claimed_by = $2",
99 )
100 .bind(event_ids)
101 .bind(worker_id)
102 .bind(retry_at)
103 .bind(failure_code)
104 .execute(&self.pool)
105 .await
106 }
107 JournalTransition::Quarantine { failure_code } => {
108 validate_failure_code(failure_code)?;
109 sqlx::query(
110 "UPDATE minco_audit_journal
111 SET status = 'quarantined', failure_code = $3,
112 claimed_by = NULL, claim_expires_at = NULL
113 WHERE event_id = ANY($1) AND status = 'claimed' AND claimed_by = $2",
114 )
115 .bind(event_ids)
116 .bind(worker_id)
117 .bind(failure_code)
118 .execute(&self.pool)
119 .await
120 }
121 }
122 .map_err(infrastructure)?;
123 if usize::try_from(result.rows_affected()).ok() != Some(event_ids.len()) {
124 return Err(AuditLedgerError::JournalClaimLost);
125 }
126 Ok(())
127 }
128}
129
130enum JournalTransition<'a> {
131 Delivered,
132 Retry {
133 failure_code: &'a str,
134 retry_at: DateTime<Utc>,
135 },
136 Quarantine {
137 failure_code: &'a str,
138 },
139}
140
141#[async_trait]
142impl AuditJournalStore for PostgresAuditJournal {
143 async fn enqueue(&self, entry: AuditJournalEntry) -> Result<(), AuditLedgerError> {
144 let mut transaction = self.pool.begin().await.map_err(infrastructure)?;
145 self.enqueue_in(&mut transaction, entry).await?;
146 transaction.commit().await.map_err(infrastructure)
147 }
148
149 async fn claim_pending(
150 &self,
151 worker_id: &str,
152 limit: usize,
153 claim_expires_at: DateTime<Utc>,
154 ) -> Result<Vec<AuditJournalEntry>, AuditLedgerError> {
155 validate_claim(worker_id, limit, claim_expires_at)?;
156 let limit = i64::try_from(limit).map_err(|_| AuditLedgerError::InvalidJournalClaim)?;
157 sqlx::query(
158 "WITH claimable AS (
159 SELECT event_id FROM minco_audit_journal
160 WHERE status IN ('pending', 'failed') AND available_at <= $1
161 ORDER BY available_at, occurred_at, event_id
162 FOR UPDATE SKIP LOCKED LIMIT $2
163 )
164 UPDATE minco_audit_journal AS journal
165 SET status = 'claimed', claimed_by = $3, claim_expires_at = $4,
166 attempt_count = journal.attempt_count + 1
167 FROM claimable
168 WHERE journal.event_id = claimable.event_id
169 RETURNING journal.*",
170 )
171 .bind(Utc::now())
172 .bind(limit)
173 .bind(worker_id)
174 .bind(claim_expires_at)
175 .fetch_all(&self.pool)
176 .await
177 .map_err(infrastructure)?
178 .iter()
179 .map(decode_journal_entry)
180 .collect()
181 }
182
183 async fn mark_delivered(
184 &self,
185 event_ids: &[Uuid],
186 worker_id: &str,
187 ) -> Result<(), AuditLedgerError> {
188 self.transition(event_ids, worker_id, JournalTransition::Delivered)
189 .await
190 }
191
192 async fn mark_retry(
193 &self,
194 event_ids: &[Uuid],
195 worker_id: &str,
196 failure_code: &str,
197 retry_at: DateTime<Utc>,
198 ) -> Result<(), AuditLedgerError> {
199 self.transition(
200 event_ids,
201 worker_id,
202 JournalTransition::Retry {
203 failure_code,
204 retry_at,
205 },
206 )
207 .await
208 }
209
210 async fn quarantine(
211 &self,
212 event_ids: &[Uuid],
213 worker_id: &str,
214 failure_code: &str,
215 ) -> Result<(), AuditLedgerError> {
216 self.transition(
217 event_ids,
218 worker_id,
219 JournalTransition::Quarantine { failure_code },
220 )
221 .await
222 }
223
224 async fn recover_expired_claims(&self, now: DateTime<Utc>) -> Result<usize, AuditLedgerError> {
225 let result = sqlx::query(
226 "UPDATE minco_audit_journal
227 SET status = 'failed', available_at = $1, claimed_by = NULL,
228 claim_expires_at = NULL, failure_code = 'AUDIT-CLAIM-EXPIRED'
229 WHERE status = 'claimed' AND claim_expires_at <= $1",
230 )
231 .bind(now)
232 .execute(&self.pool)
233 .await
234 .map_err(infrastructure)?;
235 usize::try_from(result.rows_affected()).map_err(|_| AuditLedgerError::Infrastructure)
236 }
237}
238
239fn decode_journal_entry(row: &PgRow) -> Result<AuditJournalEntry, AuditLedgerError> {
240 let value: serde_json::Value = row.try_get("record").map_err(infrastructure)?;
241 let status: String = row.try_get("status").map_err(infrastructure)?;
242 let record: AuditRecordV2 =
243 serde_json::from_value(value).map_err(|_| AuditLedgerError::Encoding)?;
244 let encoded_bytes: i32 = row.try_get("encoded_bytes").map_err(infrastructure)?;
245 let attempt_count: i32 = row.try_get("attempt_count").map_err(infrastructure)?;
246 Ok(AuditJournalEntry {
247 record,
248 status: decode_status(&status)?,
249 attempt_count: u32::try_from(attempt_count)
250 .map_err(|_| AuditLedgerError::Infrastructure)?,
251 encoded_bytes: usize::try_from(encoded_bytes)
252 .map_err(|_| AuditLedgerError::Infrastructure)?,
253 available_at: row.try_get("available_at").map_err(infrastructure)?,
254 claimed_by: row.try_get("claimed_by").map_err(infrastructure)?,
255 claim_expires_at: row.try_get("claim_expires_at").map_err(infrastructure)?,
256 failure_code: row.try_get("failure_code").map_err(infrastructure)?,
257 })
258}
259
260fn decode_status(value: &str) -> Result<AuditJournalStatus, AuditLedgerError> {
261 match value {
262 "pending" => Ok(AuditJournalStatus::Pending),
263 "claimed" => Ok(AuditJournalStatus::Claimed),
264 "failed" => Ok(AuditJournalStatus::Failed),
265 "quarantined" => Ok(AuditJournalStatus::Quarantined),
266 _ => Err(AuditLedgerError::Infrastructure),
267 }
268}
269
270#[derive(Debug, Clone)]
271pub struct PostgresAuditLedger {
272 pool: PgPool,
273}
274
275impl PostgresAuditLedger {
276 pub const fn new(pool: PgPool) -> Self {
277 Self { pool }
278 }
279}
280
281#[async_trait]
282impl AuditLedgerWriter for PostgresAuditLedger {
283 async fn append_batch(
284 &self,
285 records: &[AuditRecordV2],
286 ) -> Result<AuditAppendReport, AuditLedgerError> {
287 let prepared = prepare_batch(records)?;
288 let mut transaction = self.pool.begin().await.map_err(infrastructure)?;
289 let ids = prepared.keys().copied().collect::<Vec<_>>();
290 let existing = fetch_existing_records(&mut transaction, &ids).await?;
291 let mut new = Vec::new();
292 let mut duplicates = records.len().saturating_sub(prepared.len());
293 for (event_id, item) in &prepared {
294 match existing.get(event_id) {
295 Some(value) if value == &item.1 => duplicates += 1,
296 Some(_) => return Err(AuditLedgerError::EventConflict(*event_id)),
297 None => new.push(item),
298 }
299 }
300 let inserted = if new.is_empty() {
301 BTreeSet::new()
302 } else {
303 let inserted = insert_records(&mut transaction, &new).await?;
304 if inserted.len() != new.len() {
305 let raced_ids = new
306 .iter()
307 .filter(|item| !inserted.contains(&item.0.event_id))
308 .map(|item| item.0.event_id)
309 .collect::<Vec<_>>();
310 let raced = fetch_existing_records(&mut transaction, &raced_ids).await?;
311 for item in &new {
312 if !inserted.contains(&item.0.event_id)
313 && raced.get(&item.0.event_id) != Some(&item.1)
314 {
315 return Err(AuditLedgerError::EventConflict(item.0.event_id));
316 }
317 }
318 duplicates += raced_ids.len();
319 }
320 insert_related_records(&mut transaction, &new, &inserted).await?;
321 inserted
322 };
323 transaction.commit().await.map_err(infrastructure)?;
324 Ok(AuditAppendReport {
325 requested: records.len(),
326 inserted: inserted.len(),
327 duplicates,
328 })
329 }
330}
331
332type PreparedBatch = BTreeMap<Uuid, (AuditRecordV2, serde_json::Value, usize)>;
333
334fn prepare_batch(records: &[AuditRecordV2]) -> Result<PreparedBatch, AuditLedgerError> {
335 if records.is_empty() || records.len() > minco_plugin_audit::MAX_AUDIT_BATCH_RECORDS {
336 return Err(AuditLedgerError::InvalidBatch(
337 "invalid record count".into(),
338 ));
339 }
340 let mut prepared = BTreeMap::new();
341 let mut bytes = 0usize;
342 for record in records {
343 let encoded_bytes = record.validate()?;
344 bytes = bytes
345 .checked_add(encoded_bytes)
346 .ok_or_else(|| AuditLedgerError::InvalidBatch("batch bytes overflow".into()))?;
347 if bytes > minco_plugin_audit::MAX_AUDIT_BATCH_BYTES {
348 return Err(AuditLedgerError::BatchTooLarge {
349 bytes,
350 maximum: minco_plugin_audit::MAX_AUDIT_BATCH_BYTES,
351 });
352 }
353 let value = serde_json::to_value(record).map_err(|_| AuditLedgerError::Encoding)?;
354 if let Some(existing) =
355 prepared.insert(record.event_id, (record.clone(), value, encoded_bytes))
356 && existing.0 != *record
357 {
358 return Err(AuditLedgerError::EventConflict(record.event_id));
359 }
360 }
361 Ok(prepared)
362}
363
364async fn fetch_existing_records(
365 transaction: &mut Transaction<'_, Postgres>,
366 ids: &[Uuid],
367) -> Result<BTreeMap<Uuid, serde_json::Value>, AuditLedgerError> {
368 sqlx::query("SELECT event_id, record FROM minco_audit_records WHERE event_id = ANY($1)")
369 .bind(ids)
370 .fetch_all(&mut **transaction)
371 .await
372 .map_err(infrastructure)?
373 .iter()
374 .map(|row| {
375 Ok((
376 row.try_get("event_id").map_err(infrastructure)?,
377 row.try_get("record").map_err(infrastructure)?,
378 ))
379 })
380 .collect()
381}
382
383async fn insert_records(
384 transaction: &mut Transaction<'_, Postgres>,
385 records: &[&(AuditRecordV2, serde_json::Value, usize)],
386) -> Result<BTreeSet<Uuid>, AuditLedgerError> {
387 let mut insert = QueryBuilder::<Postgres>::new(
388 "INSERT INTO minco_audit_records
389 (event_id, tenant_scope, resource_type, resource_id, occurred_at,
390 recorded_at, encoded_bytes, record) ",
391 );
392 insert.push_values(records, |mut row, item| {
393 row.push_bind(item.0.event_id)
394 .push_bind(&item.0.tenant_scope)
395 .push_bind(&item.0.resource.resource_type)
396 .push_bind(&item.0.resource.resource_id)
397 .push_bind(item.0.occurred_at)
398 .push_bind(item.0.recorded_at)
399 .push_bind(i32::try_from(item.2).expect("validated audit record size"))
400 .push_bind(&item.1);
401 });
402 insert.push(" ON CONFLICT(event_id) DO NOTHING RETURNING event_id");
403 insert
404 .build()
405 .fetch_all(&mut **transaction)
406 .await
407 .map_err(infrastructure)?
408 .iter()
409 .map(|row| row.try_get("event_id").map_err(infrastructure))
410 .collect()
411}
412
413async fn insert_related_records(
414 transaction: &mut Transaction<'_, Postgres>,
415 records: &[&(AuditRecordV2, serde_json::Value, usize)],
416 inserted: &BTreeSet<Uuid>,
417) -> Result<(), AuditLedgerError> {
418 let related = records
419 .iter()
420 .filter(|item| inserted.contains(&item.0.event_id))
421 .flat_map(|item| {
422 item.0
423 .related_resources
424 .iter()
425 .map(move |related| (&item.0, related))
426 })
427 .collect::<Vec<_>>();
428 if related.is_empty() {
429 return Ok(());
430 }
431 let mut insert = QueryBuilder::<Postgres>::new(
432 "INSERT INTO minco_audit_related_resources
433 (event_id, tenant_scope, relation, resource_type, resource_id, occurred_at) ",
434 );
435 insert.push_values(related, |mut row, (record, related)| {
436 row.push_bind(record.event_id)
437 .push_bind(&record.tenant_scope)
438 .push_bind(&related.relation)
439 .push_bind(&related.resource.resource_type)
440 .push_bind(&related.resource.resource_id)
441 .push_bind(record.occurred_at);
442 });
443 insert
444 .build()
445 .execute(&mut **transaction)
446 .await
447 .map_err(infrastructure)?;
448 Ok(())
449}
450
451#[async_trait]
452impl AuditReader for PostgresAuditLedger {
453 async fn list_resource_history(
454 &self,
455 query: &AuditQuery,
456 ) -> Result<AuditPage, AuditLedgerError> {
457 query.validate()?;
458 let mut statement = QueryBuilder::<Postgres>::new(
459 "SELECT record, occurred_at, event_id FROM minco_audit_records AS audit WHERE tenant_scope = ",
460 );
461 statement
462 .push_bind(&query.tenant_scope)
463 .push(" AND ((resource_type = ")
464 .push_bind(&query.resource.resource_type)
465 .push(" AND resource_id = ")
466 .push_bind(&query.resource.resource_id)
467 .push(")");
468 if query.include_related {
469 statement.push(
470 " OR EXISTS (SELECT 1 FROM minco_audit_related_resources AS related
471 WHERE related.event_id = audit.event_id AND related.tenant_scope = ",
472 );
473 statement
474 .push_bind(&query.tenant_scope)
475 .push(" AND related.resource_type = ")
476 .push_bind(&query.resource.resource_type)
477 .push(" AND related.resource_id = ")
478 .push_bind(&query.resource.resource_id);
479 if let Some(relation) = &query.relation {
480 statement
481 .push(" AND related.relation = ")
482 .push_bind(relation);
483 }
484 statement.push(")");
485 }
486 statement.push(")");
487 if let Some(after) = query.after {
488 let comparator = match query.direction {
489 minco_plugin_audit::AuditSortDirection::OldestFirst => ">",
490 minco_plugin_audit::AuditSortDirection::NewestFirst => "<",
491 };
492 statement
493 .push(" AND (occurred_at, event_id) ")
494 .push(comparator)
495 .push(" (")
496 .push_bind(after.occurred_at)
497 .push(", ")
498 .push_bind(after.event_id)
499 .push(")");
500 }
501 let direction = match query.direction {
502 minco_plugin_audit::AuditSortDirection::OldestFirst => "ASC",
503 minco_plugin_audit::AuditSortDirection::NewestFirst => "DESC",
504 };
505 statement
506 .push(" ORDER BY occurred_at ")
507 .push(direction)
508 .push(", event_id ")
509 .push(direction)
510 .push(" LIMIT ")
511 .push_bind(
512 i64::try_from(query.limit + 1)
513 .map_err(|_| AuditLedgerError::InvalidQuery("limit".into()))?,
514 );
515 let rows = statement
516 .build()
517 .fetch_all(&self.pool)
518 .await
519 .map_err(infrastructure)?;
520 decode_page(rows, query.limit)
521 }
522}
523
524fn decode_page(rows: Vec<PgRow>, limit: usize) -> Result<AuditPage, AuditLedgerError> {
525 let has_more = rows.len() > limit;
526 let records = rows
527 .into_iter()
528 .take(limit)
529 .map(|row| {
530 let value: serde_json::Value = row.try_get("record").map_err(infrastructure)?;
531 let record: AuditRecordV2 =
532 serde_json::from_value(value).map_err(|_| AuditLedgerError::Encoding)?;
533 record.validate()?;
534 Ok(record)
535 })
536 .collect::<Result<Vec<_>, AuditLedgerError>>()?;
537 let next_cursor = has_more.then(|| {
538 records
539 .last()
540 .map(AuditCursor::from)
541 .expect("positive validated query limit")
542 });
543 Ok(AuditPage {
544 records,
545 next_cursor,
546 })
547}
548
549#[derive(Debug, Clone)]
550pub struct PostgresAuditStorageInspector {
551 source: PgPool,
552 ledger: PgPool,
553 policy: AuditLifecyclePolicy,
554}
555
556impl PostgresAuditStorageInspector {
557 pub fn new(
558 source: PgPool,
559 ledger: PgPool,
560 policy: AuditLifecyclePolicy,
561 ) -> Result<Self, AuditLedgerError> {
562 policy.validate()?;
563 Ok(Self {
564 source,
565 ledger,
566 policy,
567 })
568 }
569}
570
571#[async_trait]
572impl AuditStorageInspector for PostgresAuditStorageInspector {
573 async fn storage_health(&self) -> Result<AuditStorageHealth, AuditLedgerError> {
574 let hot_bytes: i64 = sqlx::query_scalar(
575 "SELECT pg_total_relation_size('minco_audit_records')
576 + pg_total_relation_size('minco_audit_related_resources')",
577 )
578 .fetch_one(&self.ledger)
579 .await
580 .map_err(infrastructure)?;
581 let row = sqlx::query(
582 "SELECT COUNT(*) AS pending_records,
583 COALESCE(SUM(encoded_bytes), 0) AS pending_bytes,
584 MIN(occurred_at) AS oldest_pending
585 FROM minco_audit_journal WHERE status IN ('pending', 'failed', 'claimed')",
586 )
587 .fetch_one(&self.source)
588 .await
589 .map_err(infrastructure)?;
590 let pending_records: i64 = row.try_get("pending_records").map_err(infrastructure)?;
591 let pending_bytes: i64 = row.try_get("pending_bytes").map_err(infrastructure)?;
592 let oldest_pending: Option<DateTime<Utc>> =
593 row.try_get("oldest_pending").map_err(infrastructure)?;
594 let quarantined: i64 = sqlx::query_scalar(
595 "SELECT COUNT(*) FROM minco_audit_journal WHERE status = 'quarantined'",
596 )
597 .fetch_one(&self.source)
598 .await
599 .map_err(infrastructure)?;
600 let record_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM minco_audit_records")
601 .fetch_one(&self.ledger)
602 .await
603 .map_err(infrastructure)?;
604 let bounds = ledger_bounds(&self.ledger).await?;
605 let hot_bytes = u64::try_from(hot_bytes).map_err(|_| AuditLedgerError::Infrastructure)?;
606 let snapshot = AuditStorageSnapshot {
607 provider: "postgresql".into(),
608 hot_bytes,
609 free_bytes: None,
610 pending_records: u64::try_from(pending_records)
611 .map_err(|_| AuditLedgerError::Infrastructure)?,
612 pending_bytes: u64::try_from(pending_bytes)
613 .map_err(|_| AuditLedgerError::Infrastructure)?,
614 oldest_pending_seconds: oldest_pending.map(|time| {
615 u64::try_from((Utc::now() - time).num_seconds().max(0)).unwrap_or(u64::MAX)
616 }),
617 quarantined_records: u64::try_from(quarantined)
618 .map_err(|_| AuditLedgerError::Infrastructure)?,
619 archive_watermark: None,
620 segments: vec![AuditSegmentStatus {
621 segment_id: 1,
622 state: AuditSegmentState::Active,
623 record_count: u64::try_from(record_count)
624 .map_err(|_| AuditLedgerError::Infrastructure)?,
625 encoded_bytes: hot_bytes,
626 first: bounds.0,
627 last: bounds.1,
628 archive_receipt: None,
629 }],
630 };
631 evaluate_storage_health(self.policy, snapshot)
632 }
633}
634
635async fn ledger_bounds(
636 pool: &PgPool,
637) -> Result<(Option<AuditCursor>, Option<AuditCursor>), AuditLedgerError> {
638 let first = sqlx::query(
639 "SELECT occurred_at, event_id FROM minco_audit_records ORDER BY occurred_at, event_id LIMIT 1",
640 )
641 .fetch_optional(pool)
642 .await
643 .map_err(infrastructure)?
644 .map(|row| decode_cursor(&row))
645 .transpose()?;
646 let last = sqlx::query(
647 "SELECT occurred_at, event_id FROM minco_audit_records ORDER BY occurred_at DESC, event_id DESC LIMIT 1",
648 )
649 .fetch_optional(pool)
650 .await
651 .map_err(infrastructure)?
652 .map(|row| decode_cursor(&row))
653 .transpose()?;
654 Ok((first, last))
655}
656
657fn decode_cursor(row: &PgRow) -> Result<AuditCursor, AuditLedgerError> {
658 Ok(AuditCursor {
659 occurred_at: row.try_get("occurred_at").map_err(infrastructure)?,
660 event_id: row.try_get("event_id").map_err(infrastructure)?,
661 })
662}
663
664pub async fn validate_separate_audit_pools(
666 source: &PgPool,
667 ledger: &PgPool,
668) -> Result<(), AuditLedgerError> {
669 let source_identity = database_identity(source).await?;
670 let ledger_identity = database_identity(ledger).await?;
671 if source_identity == ledger_identity {
672 return Err(AuditLedgerError::InvalidLifecycle(
673 "PostgreSQL audit ledger requires a distinct database".into(),
674 ));
675 }
676 Ok(())
677}
678
679async fn database_identity(
680 pool: &PgPool,
681) -> Result<(String, Option<String>, Option<i32>), AuditLedgerError> {
682 let row = sqlx::query(
683 "SELECT current_database() AS database,
684 inet_server_addr()::text AS server_address,
685 inet_server_port() AS server_port",
686 )
687 .fetch_one(pool)
688 .await
689 .map_err(infrastructure)?;
690 Ok((
691 row.try_get("database").map_err(infrastructure)?,
692 row.try_get("server_address").map_err(infrastructure)?,
693 row.try_get("server_port").map_err(infrastructure)?,
694 ))
695}
696
697pub async fn migrate_audit_ledger(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
698 let mut migrator = sqlx::migrate!("migrations/audit-ledger");
699 migrator.dangerous_set_table_name("_minco_audit_ledger_migrations");
700 migrator.run(pool).await
701}
702
703fn validate_pending_entry(entry: &AuditJournalEntry) -> Result<(), AuditLedgerError> {
704 if entry.status != AuditJournalStatus::Pending
705 || entry.encoded_bytes != entry.record.validate()?
706 {
707 Err(AuditLedgerError::InvalidJournalEntry)
708 } else {
709 Ok(())
710 }
711}
712
713fn validate_claim(
714 worker_id: &str,
715 limit: usize,
716 claim_expires_at: DateTime<Utc>,
717) -> Result<(), AuditLedgerError> {
718 let now = Utc::now();
719 if worker_id.trim().is_empty()
720 || worker_id.len() > 128
721 || worker_id.chars().any(char::is_control)
722 || limit == 0
723 || limit > minco_plugin_audit::MAX_AUDIT_BATCH_RECORDS
724 || claim_expires_at <= now
725 || claim_expires_at > now + TimeDelta::hours(1)
726 {
727 Err(AuditLedgerError::InvalidJournalClaim)
728 } else {
729 Ok(())
730 }
731}
732
733fn validate_transition(event_ids: &[Uuid], worker_id: &str) -> Result<(), AuditLedgerError> {
734 if event_ids.is_empty()
735 || event_ids.len() > minco_plugin_audit::MAX_AUDIT_BATCH_RECORDS
736 || worker_id.trim().is_empty()
737 || worker_id.len() > 128
738 || worker_id.chars().any(char::is_control)
739 {
740 Err(AuditLedgerError::InvalidJournalClaim)
741 } else {
742 Ok(())
743 }
744}
745
746fn validate_failure_code(value: &str) -> Result<(), AuditLedgerError> {
747 if value.is_empty()
748 || value.len() > 128
749 || !value
750 .bytes()
751 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'-')
752 {
753 Err(AuditLedgerError::InvalidJournalEntry)
754 } else {
755 Ok(())
756 }
757}
758
759fn infrastructure(_: impl std::fmt::Display) -> AuditLedgerError {
760 AuditLedgerError::Infrastructure
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766 use minco_plugin_audit::{
767 AuditActor, AuditRelatedResource, AuditRelay, AuditResourceRef, AuditSortDirection,
768 };
769 use std::sync::{Arc, OnceLock};
770
771 fn test_lock() -> &'static tokio::sync::Mutex<()> {
772 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
773 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
774 }
775
776 async fn databases() -> Option<(PgPool, PgPool)> {
777 let source_url = std::env::var("MINCO_TEST_POSTGRES_URL").ok()?;
778 let ledger_url = std::env::var("MINCO_TEST_POSTGRES_AUDIT_URL").ok()?;
779 let source = PgPool::connect(&source_url).await.ok()?;
780 let ledger = PgPool::connect(&ledger_url).await.ok()?;
781 crate::plugin_adapters::migrate_plugin_storage(&source)
782 .await
783 .ok()?;
784 migrate_audit_ledger(&ledger).await.ok()?;
785 validate_separate_audit_pools(&source, &ledger).await.ok()?;
786 Some((source, ledger))
787 }
788
789 fn record() -> AuditRecordV2 {
790 let mut record = AuditRecordV2::new(
791 "tenant",
792 "order.status_changed",
793 AuditResourceRef::new("order", "one"),
794 AuditActor::human("subject"),
795 "updateOrder",
796 Uuid::now_v7(),
797 );
798 record.event_id = Uuid::now_v7();
799 record
800 }
801
802 #[tokio::test]
803 async fn transaction_and_crash_retry_are_behavioral_when_two_databases_are_configured() {
804 let _guard = test_lock().lock().await;
805 let Some((source, ledger)) = databases().await else {
806 eprintln!(
807 "MINCO_TEST_POSTGRES_URL and MINCO_TEST_POSTGRES_AUDIT_URL must name distinct databases; PostgreSQL audit proof skipped"
808 );
809 return;
810 };
811 sqlx::query("DELETE FROM minco_audit_journal")
812 .execute(&source)
813 .await
814 .unwrap();
815 sqlx::query("DELETE FROM minco_audit_related_resources")
816 .execute(&ledger)
817 .await
818 .unwrap();
819 sqlx::query("DELETE FROM minco_audit_records")
820 .execute(&ledger)
821 .await
822 .unwrap();
823
824 let journal = Arc::new(PostgresAuditJournal::new(source));
825 let ledger = Arc::new(PostgresAuditLedger::new(ledger));
826
827 let rolled_back = record();
828 let mut transaction = journal.pool.begin().await.unwrap();
829 journal
830 .enqueue_in(
831 &mut transaction,
832 AuditJournalEntry::pending(rolled_back.clone()).unwrap(),
833 )
834 .await
835 .unwrap();
836 transaction.rollback().await.unwrap();
837 let rollback_count: i64 =
838 sqlx::query_scalar("SELECT COUNT(*) FROM minco_audit_journal WHERE event_id = $1")
839 .bind(rolled_back.event_id)
840 .fetch_one(&journal.pool)
841 .await
842 .unwrap();
843 assert_eq!(rollback_count, 0);
844
845 let record = record();
846 journal
847 .enqueue(AuditJournalEntry::pending(record).unwrap())
848 .await
849 .unwrap();
850 let claimed = journal
851 .claim_pending(
852 "crashed-worker",
853 10,
854 Utc::now() + TimeDelta::milliseconds(1),
855 )
856 .await
857 .unwrap();
858 ledger
859 .append_batch(&[claimed[0].record.clone()])
860 .await
861 .unwrap();
862 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
863 let report = AuditRelay::new(journal, ledger)
864 .dispatch_once("recovery-worker", 10, TimeDelta::minutes(1))
865 .await
866 .unwrap();
867 assert_eq!((report.inserted, report.duplicates), (0, 1));
868 }
869
870 #[tokio::test]
871 async fn concurrent_claims_and_relationship_query_are_behavioral_when_configured() {
872 let _guard = test_lock().lock().await;
873 let Some((source, ledger_pool)) = databases().await else {
874 eprintln!(
875 "MINCO_TEST_POSTGRES_URL and MINCO_TEST_POSTGRES_AUDIT_URL must name distinct databases; PostgreSQL audit proof skipped"
876 );
877 return;
878 };
879 sqlx::query("DELETE FROM minco_audit_journal")
880 .execute(&source)
881 .await
882 .unwrap();
883 sqlx::query("DELETE FROM minco_audit_related_resources")
884 .execute(&ledger_pool)
885 .await
886 .unwrap();
887 sqlx::query("DELETE FROM minco_audit_records")
888 .execute(&ledger_pool)
889 .await
890 .unwrap();
891 let journal = Arc::new(PostgresAuditJournal::new(source));
892 let ledger = PostgresAuditLedger::new(ledger_pool);
893 let first = record();
894 let second = record();
895 journal
896 .enqueue(AuditJournalEntry::pending(first).unwrap())
897 .await
898 .unwrap();
899 journal
900 .enqueue(AuditJournalEntry::pending(second).unwrap())
901 .await
902 .unwrap();
903 let lease = Utc::now() + TimeDelta::minutes(1);
904 let (claim_a, claim_b) = tokio::join!(
905 journal.claim_pending("worker-a", 1, lease),
906 journal.claim_pending("worker-b", 1, lease)
907 );
908 let claim_a = claim_a.unwrap();
909 let claim_b = claim_b.unwrap();
910 assert_eq!((claim_a.len(), claim_b.len()), (1, 1));
911 assert_ne!(claim_a[0].record.event_id, claim_b[0].record.event_id);
912
913 let records = claim_a
914 .iter()
915 .chain(&claim_b)
916 .map(|entry| entry.record.clone())
917 .collect::<Vec<_>>();
918 ledger.append_batch(&records).await.unwrap();
919 journal
920 .mark_delivered(&[claim_a[0].record.event_id], "worker-a")
921 .await
922 .unwrap();
923 journal
924 .mark_delivered(&[claim_b[0].record.event_id], "worker-b")
925 .await
926 .unwrap();
927
928 let mut related = record();
929 related.resource = AuditResourceRef::new("shift", "one");
930 related.related_resources.push(AuditRelatedResource {
931 relation: "order".into(),
932 resource: AuditResourceRef::new("order", "related-one"),
933 });
934 ledger.append_batch(&[related]).await.unwrap();
935 let mut query =
936 AuditQuery::for_resource("tenant", AuditResourceRef::new("order", "related-one"));
937 query.include_related = true;
938 query.relation = Some("order".into());
939 query.direction = AuditSortDirection::OldestFirst;
940 let page = ledger.list_resource_history(&query).await.unwrap();
941 assert_eq!(page.records.len(), 1);
942 assert_eq!(page.records[0].resource.resource_type, "shift");
943 }
944}