1use std::path::Path;
10use std::thread;
11use std::time::Duration;
12
13use ito_domain::audit::event::AuditEvent;
14use ito_domain::backend::{BackendError, BackendEventIngestClient, EventBatch};
15
16use crate::backend_client::{idempotency_key, is_retriable_status};
17use crate::errors::{CoreError, CoreResult};
18
19const DEFAULT_BATCH_SIZE: usize = 100;
21
22const CHECKPOINT_FILE: &str = "event-forward-offset";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ForwardResult {
28 pub forwarded: usize,
30 pub duplicates: usize,
32 pub failed_batches: usize,
34 pub total_local: usize,
36 pub new_offset: usize,
38}
39
40#[derive(Debug, Clone)]
42pub struct ForwarderConfig {
43 pub batch_size: usize,
45 pub max_retries: u32,
47 pub retry_base_delay: Duration,
49}
50
51impl Default for ForwarderConfig {
52 fn default() -> Self {
53 Self {
54 batch_size: DEFAULT_BATCH_SIZE,
55 max_retries: 3,
56 retry_base_delay: Duration::from_millis(500),
57 }
58 }
59}
60
61pub fn forward_events(
70 ingest_client: &dyn BackendEventIngestClient,
71 ito_path: &Path,
72 config: &ForwarderConfig,
73) -> CoreResult<ForwardResult> {
74 let all_events = read_all_events(ito_path);
75 let total_local = all_events.len();
76
77 let mut current_offset = read_checkpoint(ito_path);
78 if current_offset > total_local {
79 current_offset = total_local;
80 }
81
82 if current_offset >= total_local {
83 return Ok(ForwardResult {
84 forwarded: 0,
85 duplicates: 0,
86 failed_batches: 0,
87 total_local,
88 new_offset: current_offset,
89 });
90 }
91
92 let batch_size = config.batch_size.max(1);
93 let new_events = &all_events[current_offset..];
94 let mut forwarded = 0usize;
95 let mut duplicates = 0usize;
96 let mut failed_batches = 0usize;
97 let mut offset = current_offset;
98
99 for chunk in new_events.chunks(batch_size) {
100 let batch = EventBatch {
101 events: chunk.to_vec(),
102 idempotency_key: idempotency_key("event-forward"),
103 };
104
105 match submit_with_retry(ingest_client, &batch, config) {
106 Ok(result) => {
107 forwarded += result.accepted;
108 duplicates += result.duplicates;
109 offset += chunk.len();
110 if let Err(e) = write_checkpoint(ito_path, offset) {
112 tracing::warn!("failed to write forwarding checkpoint: {e}");
113 }
114 }
115 Err(e) => {
116 tracing::warn!("event forwarding batch failed: {e}");
117 failed_batches += 1;
118 break;
120 }
121 }
122 }
123
124 Ok(ForwardResult {
125 forwarded,
126 duplicates,
127 failed_batches,
128 total_local,
129 new_offset: offset,
130 })
131}
132
133fn submit_with_retry(
135 client: &dyn BackendEventIngestClient,
136 batch: &EventBatch,
137 config: &ForwarderConfig,
138) -> CoreResult<ito_domain::backend::EventIngestResult> {
139 let mut attempts = 0u32;
140 loop {
141 match client.ingest(batch) {
142 Ok(result) => return Ok(result),
143 Err(err) => {
144 attempts += 1;
145 if !is_retriable_backend_error(&err) || attempts > config.max_retries {
146 return Err(backend_ingest_error_to_core(err));
147 }
148 let delay = config.retry_base_delay * 2u32.saturating_pow(attempts - 1);
150 thread::sleep(delay);
151 }
152 }
153 }
154}
155
156fn is_retriable_backend_error(err: &BackendError) -> bool {
158 match err {
159 BackendError::Unavailable(_) => true,
160 BackendError::Other(msg) => {
161 if let Some(code_str) = msg.strip_prefix("HTTP ")
163 && let Ok(code) = code_str
164 .chars()
165 .take_while(|c| c.is_ascii_digit())
166 .collect::<String>()
167 .parse::<u16>()
168 {
169 return is_retriable_status(code);
170 }
171 false
172 }
173 BackendError::Unauthorized(_) => false,
174 BackendError::NotFound(_) => false,
175 BackendError::LeaseConflict(_) => false,
176 BackendError::RevisionConflict(_) => false,
177 }
178}
179
180fn backend_ingest_error_to_core(err: BackendError) -> CoreError {
182 match err {
183 BackendError::Unavailable(msg) => CoreError::process(format!(
184 "Backend unavailable during event forwarding: {msg}"
185 )),
186 BackendError::Unauthorized(msg) => CoreError::validation(format!(
187 "Backend auth failed during event forwarding: {msg}"
188 )),
189 BackendError::NotFound(msg) => {
190 CoreError::not_found(format!("Backend ingest endpoint not found: {msg}"))
191 }
192 BackendError::Other(msg) => {
193 CoreError::process(format!("Backend error during event forwarding: {msg}"))
194 }
195 BackendError::LeaseConflict(c) => CoreError::process(format!(
196 "Unexpected lease conflict during event forwarding: {}",
197 c.change_id
198 )),
199 BackendError::RevisionConflict(c) => CoreError::process(format!(
200 "Unexpected revision conflict during event forwarding: {}",
201 c.change_id
202 )),
203 }
204}
205
206fn checkpoint_path(ito_path: &Path) -> std::path::PathBuf {
210 ito_path.join(".state").join(CHECKPOINT_FILE)
211}
212
213fn read_checkpoint(ito_path: &Path) -> usize {
217 let path = checkpoint_path(ito_path);
218 let Ok(content) = std::fs::read_to_string(&path) else {
219 return 0;
220 };
221 content.trim().parse::<usize>().unwrap_or(0)
222}
223
224fn write_checkpoint(ito_path: &Path, offset: usize) -> CoreResult<()> {
226 let path = checkpoint_path(ito_path);
227 if let Some(parent) = path.parent() {
228 std::fs::create_dir_all(parent)
229 .map_err(|e| CoreError::io("creating checkpoint directory", e))?;
230 }
231 std::fs::write(&path, offset.to_string())
232 .map_err(|e| CoreError::io("writing forwarding checkpoint", e))
233}
234
235fn read_all_events(ito_path: &Path) -> Vec<AuditEvent> {
239 crate::audit::default_audit_store(ito_path).read_all()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
246 use ito_domain::backend::{BackendError, EventIngestResult};
247 use std::path::Path;
248 use std::sync::Mutex;
249 use std::sync::atomic::{AtomicUsize, Ordering};
250
251 fn run_git(repo: &Path, args: &[&str]) {
252 let output = std::process::Command::new("git")
253 .args(args)
254 .current_dir(repo)
255 .env_remove("GIT_DIR")
256 .env_remove("GIT_WORK_TREE")
257 .output()
258 .expect("git should run");
259 assert!(
260 output.status.success(),
261 "git command failed: git {}\nstdout:\n{}\nstderr:\n{}",
262 args.join(" "),
263 String::from_utf8_lossy(&output.stdout),
264 String::from_utf8_lossy(&output.stderr)
265 );
266 }
267
268 fn init_git_repo(repo: &Path) {
269 run_git(repo, &["init"]);
270 run_git(repo, &["config", "user.email", "test@example.com"]);
271 run_git(repo, &["config", "user.name", "Test User"]);
272 run_git(repo, &["config", "commit.gpgsign", "false"]);
273 std::fs::write(repo.join("README.md"), "hi\n").expect("write readme");
274 run_git(repo, &["add", "README.md"]);
275 run_git(repo, &["commit", "-m", "initial"]);
276 }
277
278 fn make_event(id: &str) -> AuditEvent {
279 AuditEvent {
280 v: SCHEMA_VERSION,
281 ts: "2026-02-28T10:00:00.000Z".to_string(),
282 entity: "task".to_string(),
283 entity_id: id.to_string(),
284 scope: Some("test-change".to_string()),
285 op: "create".to_string(),
286 from: None,
287 to: Some("pending".to_string()),
288 actor: "cli".to_string(),
289 by: "@test".to_string(),
290 meta: None,
291 count: 1,
292 ctx: EventContext {
293 session_id: "test-sid".to_string(),
294 harness_session_id: None,
295 branch: None,
296 worktree: None,
297 commit: None,
298 },
299 }
300 }
301
302 struct FakeIngestClient {
304 call_count: AtomicUsize,
305 results: Mutex<Vec<Result<EventIngestResult, BackendError>>>,
306 }
307
308 impl FakeIngestClient {
309 fn always_ok() -> Self {
310 Self {
311 call_count: AtomicUsize::new(0),
312 results: Mutex::new(Vec::new()),
313 }
314 }
315
316 fn with_results(results: Vec<Result<EventIngestResult, BackendError>>) -> Self {
317 Self {
318 call_count: AtomicUsize::new(0),
319 results: Mutex::new(results),
320 }
321 }
322
323 fn calls(&self) -> usize {
324 self.call_count.load(Ordering::SeqCst)
325 }
326 }
327
328 impl BackendEventIngestClient for FakeIngestClient {
329 fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError> {
330 let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
331 let results = self.results.lock().unwrap();
332 if idx < results.len() {
333 return results[idx].clone();
334 }
335 Ok(EventIngestResult {
337 accepted: batch.events.len(),
338 duplicates: 0,
339 })
340 }
341 }
342
343 fn write_events_to_log(ito_path: &Path, events: &[AuditEvent]) {
344 let writer = crate::audit::default_audit_store(ito_path);
345 for event in events {
346 crate::audit::AuditWriter::append(writer.as_ref(), event).unwrap();
347 }
348 }
349
350 #[test]
351 fn forward_reads_events_from_routed_local_store() {
352 let tmp = tempfile::tempdir().unwrap();
353 init_git_repo(tmp.path());
354 let ito_path = tmp.path().join(".ito");
355 std::fs::create_dir_all(&ito_path).unwrap();
356
357 let store = crate::audit::default_audit_store(&ito_path);
358 crate::audit::AuditWriter::append(store.as_ref(), &make_event("1.1")).unwrap();
359
360 let client = FakeIngestClient::always_ok();
361 let config = ForwarderConfig::default();
362 let result = forward_events(&client, &ito_path, &config).unwrap();
363
364 assert_eq!(result.forwarded, 1);
365 assert_eq!(result.total_local, 1);
366 assert_eq!(client.calls(), 1);
367 }
368
369 #[test]
370 fn forward_no_events_returns_zero() {
371 let tmp = tempfile::tempdir().unwrap();
372 let ito_path = tmp.path().join(".ito");
373 let client = FakeIngestClient::always_ok();
374 let config = ForwarderConfig::default();
375
376 let result = forward_events(&client, &ito_path, &config).unwrap();
377 assert_eq!(result.forwarded, 0);
378 assert_eq!(result.total_local, 0);
379 assert_eq!(result.new_offset, 0);
380 assert_eq!(result.failed_batches, 0);
381 assert_eq!(client.calls(), 0);
382 }
383
384 #[test]
385 fn forward_sends_all_new_events() {
386 let tmp = tempfile::tempdir().unwrap();
387 let ito_path = tmp.path().join(".ito");
388
389 let events: Vec<AuditEvent> = (0..5).map(|i| make_event(&format!("1.{i}"))).collect();
390 write_events_to_log(&ito_path, &events);
391
392 let client = FakeIngestClient::always_ok();
393 let config = ForwarderConfig {
394 batch_size: 10,
395 ..ForwarderConfig::default()
396 };
397
398 let result = forward_events(&client, &ito_path, &config).unwrap();
399 assert_eq!(result.forwarded, 5);
400 assert_eq!(result.total_local, 5);
401 assert_eq!(result.new_offset, 5);
402 assert_eq!(result.failed_batches, 0);
403 assert_eq!(client.calls(), 1); }
405
406 #[test]
407 fn forward_respects_checkpoint() {
408 let tmp = tempfile::tempdir().unwrap();
409 let ito_path = tmp.path().join(".ito");
410
411 let events: Vec<AuditEvent> = (0..5).map(|i| make_event(&format!("1.{i}"))).collect();
412 write_events_to_log(&ito_path, &events);
413
414 write_checkpoint(&ito_path, 3).unwrap();
416
417 let client = FakeIngestClient::always_ok();
418 let config = ForwarderConfig::default();
419
420 let result = forward_events(&client, &ito_path, &config).unwrap();
421 assert_eq!(result.forwarded, 2); assert_eq!(result.new_offset, 5);
423 }
424
425 #[test]
426 fn forward_skips_when_fully_forwarded() {
427 let tmp = tempfile::tempdir().unwrap();
428 let ito_path = tmp.path().join(".ito");
429
430 let events: Vec<AuditEvent> = (0..3).map(|i| make_event(&format!("1.{i}"))).collect();
431 write_events_to_log(&ito_path, &events);
432 write_checkpoint(&ito_path, 3).unwrap();
433
434 let client = FakeIngestClient::always_ok();
435 let config = ForwarderConfig::default();
436
437 let result = forward_events(&client, &ito_path, &config).unwrap();
438 assert_eq!(result.forwarded, 0);
439 assert_eq!(result.new_offset, 3);
440 assert_eq!(client.calls(), 0);
441 }
442
443 #[test]
444 fn forward_batches_correctly() {
445 let tmp = tempfile::tempdir().unwrap();
446 let ito_path = tmp.path().join(".ito");
447
448 let events: Vec<AuditEvent> = (0..7).map(|i| make_event(&format!("1.{i}"))).collect();
449 write_events_to_log(&ito_path, &events);
450
451 let client = FakeIngestClient::always_ok();
452 let config = ForwarderConfig {
453 batch_size: 3,
454 ..ForwarderConfig::default()
455 };
456
457 let result = forward_events(&client, &ito_path, &config).unwrap();
458 assert_eq!(result.forwarded, 7);
459 assert_eq!(client.calls(), 3); }
461
462 #[test]
463 fn forward_stops_on_permanent_failure() {
464 let tmp = tempfile::tempdir().unwrap();
465 let ito_path = tmp.path().join(".ito");
466
467 let events: Vec<AuditEvent> = (0..6).map(|i| make_event(&format!("1.{i}"))).collect();
468 write_events_to_log(&ito_path, &events);
469
470 let client = FakeIngestClient::with_results(vec![
471 Ok(EventIngestResult {
472 accepted: 3,
473 duplicates: 0,
474 }),
475 Err(BackendError::Unauthorized("bad token".to_string())),
476 ]);
477 let config = ForwarderConfig {
478 batch_size: 3,
479 max_retries: 0,
480 retry_base_delay: Duration::from_millis(1),
481 };
482
483 let result = forward_events(&client, &ito_path, &config).unwrap();
484 assert_eq!(result.forwarded, 3); assert_eq!(result.failed_batches, 1);
486 assert_eq!(result.new_offset, 3); }
488
489 #[test]
490 fn forward_retries_transient_failure() {
491 let tmp = tempfile::tempdir().unwrap();
492 let ito_path = tmp.path().join(".ito");
493
494 let events: Vec<AuditEvent> = (0..2).map(|i| make_event(&format!("1.{i}"))).collect();
495 write_events_to_log(&ito_path, &events);
496
497 let client = FakeIngestClient::with_results(vec![
498 Err(BackendError::Unavailable("timeout".to_string())),
499 Ok(EventIngestResult {
500 accepted: 2,
501 duplicates: 0,
502 }),
503 ]);
504 let config = ForwarderConfig {
505 batch_size: 10,
506 max_retries: 3,
507 retry_base_delay: Duration::from_millis(1),
508 };
509
510 let result = forward_events(&client, &ito_path, &config).unwrap();
511 assert_eq!(result.forwarded, 2);
512 assert_eq!(result.failed_batches, 0);
513 assert_eq!(client.calls(), 2); }
515
516 #[test]
517 fn forward_reports_duplicates() {
518 let tmp = tempfile::tempdir().unwrap();
519 let ito_path = tmp.path().join(".ito");
520
521 let events: Vec<AuditEvent> = (0..3).map(|i| make_event(&format!("1.{i}"))).collect();
522 write_events_to_log(&ito_path, &events);
523
524 let client = FakeIngestClient::with_results(vec![Ok(EventIngestResult {
525 accepted: 1,
526 duplicates: 2,
527 })]);
528 let config = ForwarderConfig::default();
529
530 let result = forward_events(&client, &ito_path, &config).unwrap();
531 assert_eq!(result.forwarded, 1);
532 assert_eq!(result.duplicates, 2);
533 }
534
535 #[test]
536 fn checkpoint_roundtrip() {
537 let tmp = tempfile::tempdir().unwrap();
538 let ito_path = tmp.path().join(".ito");
539
540 assert_eq!(read_checkpoint(&ito_path), 0);
541
542 write_checkpoint(&ito_path, 42).unwrap();
543 assert_eq!(read_checkpoint(&ito_path), 42);
544
545 write_checkpoint(&ito_path, 100).unwrap();
546 assert_eq!(read_checkpoint(&ito_path), 100);
547 }
548
549 #[test]
550 fn checkpoint_missing_returns_zero() {
551 let tmp = tempfile::tempdir().unwrap();
552 let ito_path = tmp.path().join(".ito");
553 assert_eq!(read_checkpoint(&ito_path), 0);
554 }
555
556 #[test]
557 fn is_retriable_backend_error_checks() {
558 assert!(is_retriable_backend_error(&BackendError::Unavailable(
559 "timeout".to_string()
560 )));
561 assert!(!is_retriable_backend_error(&BackendError::Unauthorized(
562 "bad".to_string()
563 )));
564 assert!(!is_retriable_backend_error(&BackendError::NotFound(
565 "nope".to_string()
566 )));
567 }
568
569 #[test]
570 fn forward_persists_checkpoint_per_batch() {
571 let tmp = tempfile::tempdir().unwrap();
572 let ito_path = tmp.path().join(".ito");
573
574 let events: Vec<AuditEvent> = (0..4).map(|i| make_event(&format!("1.{i}"))).collect();
575 write_events_to_log(&ito_path, &events);
576
577 let client = FakeIngestClient::always_ok();
578 let config = ForwarderConfig {
579 batch_size: 2,
580 ..ForwarderConfig::default()
581 };
582
583 forward_events(&client, &ito_path, &config).unwrap();
584 assert_eq!(read_checkpoint(&ito_path), 4);
586 }
587
588 #[test]
589 fn forward_result_equality() {
590 let a = ForwardResult {
591 forwarded: 5,
592 duplicates: 0,
593 failed_batches: 0,
594 total_local: 5,
595 new_offset: 5,
596 };
597 let b = a.clone();
598 assert_eq!(a, b);
599 }
600}