1use std::sync::Arc;
4use std::time::Duration;
5
6use aster_forge_db::{AuditLogCreate, DbError, create_audit_log_requests};
7use aster_forge_runtime::{
8 BufferFlushError, BufferStopReport, BufferedBatchConfig, BufferedWriterEvent,
9 BufferedWriterSnapshot, BufferedWriterState, ManagedBatchWriter, OverflowPolicy,
10 RecordAdmission, RecordError, RuntimeComponentBundle, RuntimeComponentBundleRegistration,
11 RuntimeComponentKind, RuntimeComponentRegistry, StartupPhaseFailurePolicy, WriteFailure,
12 WriteFailureClass, runtime_component,
13};
14use sea_orm::DatabaseConnection;
15use tokio::sync::mpsc;
16use tokio::time::Instant;
17
18use crate::{AUDIT_LOGS_COMPONENT, AUDIT_MANAGER_COMPONENT, AUDIT_MANAGER_FLUSH_SHUTDOWN_PHASE};
19
20pub const DEFAULT_AUDIT_LOG_QUEUE_CAPACITY: usize = 4096;
22pub const DEFAULT_AUDIT_LOG_BATCH_SIZE: usize = 100;
24pub const DEFAULT_AUDIT_LOG_DELAYED_FLUSH_AFTER: Duration = Duration::from_secs(1);
26pub const AUDIT_WRITER_START_PHASE: &str = "audit_writer_start";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct AuditLogBufferConfig {
32 pub queue_capacity: usize,
34 pub batch_size: usize,
36 pub delayed_flush_after: Duration,
38}
39
40impl AuditLogBufferConfig {
41 #[must_use]
43 pub const fn new(
44 queue_capacity: usize,
45 batch_size: usize,
46 delayed_flush_after: Duration,
47 ) -> Self {
48 Self {
49 queue_capacity,
50 batch_size,
51 delayed_flush_after,
52 }
53 }
54}
55
56impl Default for AuditLogBufferConfig {
57 fn default() -> Self {
58 Self::new(
59 DEFAULT_AUDIT_LOG_QUEUE_CAPACITY,
60 DEFAULT_AUDIT_LOG_BATCH_SIZE,
61 DEFAULT_AUDIT_LOG_DELAYED_FLUSH_AFTER,
62 )
63 }
64}
65
66pub struct AuditWriter {
73 writer: ManagedBatchWriter<AuditLogCreate, DbError>,
74}
75
76impl AuditWriter {
77 #[must_use]
79 pub fn new(db: DatabaseConnection) -> Self {
80 Self::with_config(
81 db,
82 AuditLogBufferConfig::default(),
83 OverflowPolicy::DirectWrite,
84 None,
85 )
86 }
87
88 #[must_use]
91 pub fn with_config(
92 db: DatabaseConnection,
93 config: AuditLogBufferConfig,
94 overflow: OverflowPolicy,
95 events: Option<mpsc::Sender<BufferedWriterEvent>>,
96 ) -> Self {
97 let writer = ManagedBatchWriter::new(
98 BufferedBatchConfig::new(
99 config.queue_capacity,
100 config.batch_size,
101 config.delayed_flush_after,
102 ),
103 overflow,
104 events,
105 move |requests: Vec<AuditLogCreate>| {
106 let db = db.clone();
107 async move {
108 let retry = aster_forge_db::retry::RetryConfig {
111 max_retries: 0,
112 ..aster_forge_db::retry::RetryConfig::deadlock()
113 };
114 aster_forge_db::transaction::with_transaction_retry(
115 &db,
116 &retry,
117 move |txn| {
118 let requests = requests.clone();
119 Box::pin(async move { create_audit_log_requests(txn, requests).await })
120 },
121 DbError::is_retryable,
122 )
123 .await
124 .map_err(classify_failure)
125 }
126 },
127 );
128 Self { writer }
129 }
130
131 pub async fn start(&self) -> Result<(), BufferedWriterState> {
136 self.writer.start().await
137 }
138 pub async fn record(
144 &self,
145 request: AuditLogCreate,
146 ) -> Result<RecordAdmission, RecordError<AuditLogCreate, DbError>> {
147 self.writer.record(request).await
148 }
149 pub fn begin_drain(&self) {
152 self.writer.begin_drain();
153 }
154 pub async fn flush(&self, deadline: Instant) -> Result<(), BufferFlushError<DbError>> {
163 self.writer.flush(deadline).await
164 }
165 pub async fn stop(&self, deadline: Instant) -> BufferStopReport<DbError> {
169 self.writer.stop(deadline).await
170 }
171 pub fn retry_failed(&self) {
174 self.writer.retry_failed();
175 }
176 #[must_use]
178 pub fn snapshot(&self) -> BufferedWriterSnapshot<DbError> {
179 self.writer.snapshot()
180 }
181 #[must_use]
184 pub fn unconfirmed(&self) -> Vec<AuditLogCreate> {
185 self.writer.unconfirmed()
186 }
187}
188
189fn classify_failure(error: DbError) -> WriteFailure<DbError> {
190 let class = if error.commit_outcome_is_unknown() {
191 WriteFailureClass::UnknownCommit
192 } else if error.is_retryable() {
193 WriteFailureClass::Retryable
194 } else {
195 WriteFailureClass::Permanent
196 };
197 WriteFailure::new(class, error)
198}
199
200pub fn audit_writer_component(
209 writer: Arc<AuditWriter>,
210 drain_timeout: Duration,
211) -> RuntimeComponentBundleRegistration<impl RuntimeComponentBundle> {
212 runtime_component(move |registry: &mut RuntimeComponentRegistry| {
213 let startup_writer = writer.clone();
214 registry
215 .component(AUDIT_MANAGER_COMPONENT)
216 .kind(RuntimeComponentKind::Product)
217 .depends_on(AUDIT_LOGS_COMPONENT)
218 .startup(
219 AUDIT_WRITER_START_PHASE,
220 StartupPhaseFailurePolicy::Required,
221 move || {
222 let writer = startup_writer.clone();
223 async move {
224 writer
225 .start()
226 .await
227 .map_err(|state| format!("audit writer cannot start from {state:?}"))
228 }
229 },
230 )
231 .shutdown(AUDIT_MANAGER_FLUSH_SHUTDOWN_PHASE, None, move || {
232 let writer = writer.clone();
233 async move { stop_writer(&writer, drain_timeout).await }
234 });
235 })
236}
237
238async fn stop_writer(writer: &AuditWriter, timeout: Duration) -> Result<(), String> {
241 let now = Instant::now();
242 let deadline = now.checked_add(timeout);
243 let report = writer.stop(deadline.unwrap_or(now)).await;
244 let remaining = report.snapshot.queued + report.snapshot.direct_pending;
245 if deadline.is_none() {
246 return Err(format!(
247 "audit drain timeout exceeds clock range; remaining={remaining}; worker joined, database dependency may close"
248 ));
249 }
250 if remaining == 0 {
251 return Ok(());
252 }
253 Err(format!(
254 "audit drain incomplete: remaining={remaining}, deadline_elapsed={}, batch_failure={:?}, direct_failure={:?}; worker joined, database dependency may close; reconcile retained records before replay",
255 report.deadline_elapsed, report.snapshot.batch_failure, report.snapshot.direct_failure
256 ))
257}
258
259pub fn audit_component_with_writer<T, StartFn, StartFut, ShutdownFn, ShutdownFut>(
266 resources: T,
267 writer: Arc<AuditWriter>,
268 drain_timeout: Duration,
269 record_server_start: StartFn,
270 record_server_shutdown: ShutdownFn,
271) -> RuntimeComponentBundleRegistration<impl RuntimeComponentBundle>
272where
273 T: Clone + Send + 'static,
274 StartFn: FnOnce(T) -> StartFut + Send + Sync + 'static,
275 StartFut: std::future::Future<Output = Result<(), String>> + Send + 'static,
276 ShutdownFn: FnOnce(T) -> ShutdownFut + Send + Sync + 'static,
277 ShutdownFut: std::future::Future<Output = Result<(), String>> + Send + 'static,
278{
279 audit_component_with_writer_after(
280 resources,
281 writer,
282 drain_timeout,
283 crate::DEFAULT_SERVER_SHUTDOWN_AUDIT_DEPENDENCIES,
284 record_server_start,
285 record_server_shutdown,
286 )
287}
288
289pub fn audit_component_with_writer_after<T, StartFn, StartFut, ShutdownFn, ShutdownFut>(
293 resources: T,
294 writer: Arc<AuditWriter>,
295 drain_timeout: Duration,
296 shutdown_dependencies: &'static [&'static str],
297 record_server_start: StartFn,
298 record_server_shutdown: ShutdownFn,
299) -> RuntimeComponentBundleRegistration<impl RuntimeComponentBundle>
300where
301 T: Clone + Send + 'static,
302 StartFn: FnOnce(T) -> StartFut + Send + Sync + 'static,
303 StartFut: std::future::Future<Output = Result<(), String>> + Send + 'static,
304 ShutdownFn: FnOnce(T) -> ShutdownFut + Send + Sync + 'static,
305 ShutdownFut: std::future::Future<Output = Result<(), String>> + Send + 'static,
306{
307 let owner = audit_writer_component(writer, drain_timeout);
308 let start = crate::server_start_audit_component(resources.clone(), record_server_start);
309 let shutdown = crate::server_shutdown_audit_component_after(
310 resources,
311 shutdown_dependencies,
312 record_server_shutdown,
313 );
314 runtime_component(move |registry: &mut RuntimeComponentRegistry| {
315 registry
316 .register_bundle(owner)
317 .register_bundle(start)
318 .register_bundle(shutdown);
319 })
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use aster_forge_db::DatabaseErrorKind;
326 use aster_forge_runtime::WriteFailureCause;
327
328 #[test]
329 fn retains_native_classification_and_unknown_commit_has_priority() {
330 let retry = classify_failure(DbError::database_operation_classified(
331 "deadlock",
332 DatabaseErrorKind::Deadlock,
333 ));
334 assert_eq!(retry.class, WriteFailureClass::Retryable);
335 let unknown = classify_failure(DbError::commit_outcome_unknown(
336 "response lost",
337 Some(DatabaseErrorKind::Deadlock),
338 ));
339 assert_eq!(unknown.class, WriteFailureClass::UnknownCommit);
340 let WriteFailureCause::Persistence(error) = unknown.cause else {
341 panic!("original error missing")
342 };
343 assert!(error.commit_outcome_is_unknown());
344 assert_eq!(
345 error.database_error_kind(),
346 Some(DatabaseErrorKind::Deadlock)
347 );
348 assert_eq!(
349 classify_failure(DbError::non_retryable("invalid record")).class,
350 WriteFailureClass::Permanent
351 );
352 }
353}