rust-py-audit 0.2.4__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/Cargo.lock +1 -1
  2. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/Cargo.toml +1 -1
  3. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/PKG-INFO +26 -8
  4. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/README.md +25 -7
  5. rust_py_audit-0.3.1/ROADMAP.md +68 -0
  6. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/pyproject.toml +1 -1
  7. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/python/rust_py_audit/__init__.py +1 -1
  8. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/audit_logger.rs +81 -47
  9. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/immutablelog_receipt.rs +12 -0
  10. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_flush_pending.py +37 -4
  11. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_immutablelog_hybrid.py +28 -3
  12. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/.github/workflows/release.yml +0 -0
  13. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/.gitignore +0 -0
  14. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/LICENSE +0 -0
  15. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/__init__.py +0 -0
  16. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/basic_usage.py +0 -0
  17. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/__init__.py +0 -0
  18. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/settings.py +0 -0
  19. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/urls.py +0 -0
  20. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/django_example/views.py +0 -0
  21. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/fastapi_app.py +0 -0
  22. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/examples/immutablelog_basic.py +0 -0
  23. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/python/rust_py_audit/django.py +0 -0
  24. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/python/rust_py_audit/fastapi.py +0 -0
  25. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/errors.rs +0 -0
  26. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/event.rs +0 -0
  27. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/hash.rs +0 -0
  28. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/immutablelog_client.rs +0 -0
  29. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/immutablelog_config.rs +0 -0
  30. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/lib.rs +0 -0
  31. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/pyconv.rs +0 -0
  32. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/retry.rs +0 -0
  33. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/storage.rs +0 -0
  34. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/src/verifier.rs +0 -0
  35. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/conftest.py +0 -0
  36. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_audit_logger.py +0 -0
  37. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_concurrency.py +0 -0
  38. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_django_immutablelog.py +0 -0
  39. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_django_middleware.py +0 -0
  40. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_fastapi_immutablelog.py +0 -0
  41. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_fastapi_middleware.py +0 -0
  42. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_hello.py +0 -0
  43. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_immutablelog_remote.py +0 -0
  44. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_metadata_bigint.py +0 -0
  45. {rust_py_audit-0.2.4 → rust_py_audit-0.3.1}/tests/test_verify.py +0 -0
@@ -1597,7 +1597,7 @@ dependencies = [
1597
1597
 
1598
1598
  [[package]]
1599
1599
  name = "rust_py_audit"
1600
- version = "0.2.4"
1600
+ version = "0.3.1"
1601
1601
  dependencies = [
1602
1602
  "httpmock",
1603
1603
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_audit"
3
- version = "0.2.4"
3
+ version = "0.3.1"
4
4
  edition = "2021"
5
5
  description = "Auditoria de eventos com cadeia de hashes (SHA-256) para apps Python, core em Rust via PyO3."
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-py-audit
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -198,7 +198,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
198
198
  |---|---|---|---|
199
199
  | `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
200
200
  | `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
201
- | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — it becomes `delivery_status="pending"` |
201
+ | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — a transient (retryable) failure becomes `status="pending"` (queued for `flush_pending()`), a permanent one becomes `status="failed"` (not queued) |
202
202
 
203
203
  `mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
204
204
 
@@ -228,11 +228,12 @@ event = audit.log(
228
228
 
229
229
  print(event["immutablelog"])
230
230
  # {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
231
- # or {"status": "pending", "tx_id": None, ...} if delivery failed (hybrid mode)
231
+ # or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
232
+ # or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)
232
233
 
233
234
  # Retries delivery of every event still marked "pending":
234
235
  print(audit.flush_pending())
235
- # {"flushed": 1, "still_pending": 0, "total": 1}
236
+ # {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
236
237
  ```
237
238
 
238
239
  ### Environment variables
@@ -319,7 +320,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
319
320
  - **Retryable**: `5xx` and timeouts.
320
321
  - **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
321
322
  - In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
322
- - In `mode="hybrid"`, the same scenario marks the event as `delivery_status="pending"`, writes it to `audit.pending.jsonl`, and never raises — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later.
323
+ - In `mode="hybrid"`, delivery never raises:
324
+ - a **retryable** failure (5xx/timeout) marks the event `status="pending"` and queues it in `audit.pending.jsonl` — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later;
325
+ - a **permanent** failure (4xx) marks the event `status="failed"` and does **not** queue it (retrying would never succeed, so it stays out of the queue instead of getting stuck there forever). The event is still recorded locally and the chain stays valid — `"failed"` is just operational metadata.
326
+
327
+ In `mode="hybrid"`, the event is appended to the local JSONL **once**, already carrying its final receipt (an O(1) append per event, not a full-file rewrite). A network failure still records the event locally (as `pending`/`failed`), so it is never lost to a failed delivery; the only loss window is the process being killed mid-request, and even then a delivery that did reach ImmutableLog is preserved in the remote store.
323
328
 
324
329
  ### Integrity guarantee
325
330
 
@@ -371,7 +376,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
371
376
  | `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
372
377
  | `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
373
378
  | `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
374
- | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"` or `"pending"`; other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
379
+ | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"`, `"pending"` (transient failure, queued), or `"failed"` (permanent failure, not queued); other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
375
380
 
376
381
  In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
377
382
 
@@ -394,10 +399,23 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
394
399
  Attempts to redeliver to ImmutableLog every event marked as `pending` (recorded in `audit.pending.jsonl`, derived from `file_path`). Only relevant in `mode="hybrid"` — other modes never populate this queue.
395
400
 
396
401
  ```python
397
- {"flushed": 1, "still_pending": 0, "total": 1}
402
+ {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
398
403
  ```
399
404
 
400
- On success, updates `event["immutablelog"]` in `audit.jsonl` (without changing `hash`) and removes the event from the queue. Events that fail again stay in the queue for the next call.
405
+ For each queued event:
406
+ - **delivered** → updates `event["immutablelog"]` in `audit.jsonl` to `"delivered"` (without changing `hash`) and removes it from the queue (counts toward `flushed`);
407
+ - **permanent failure** → marks it `"failed"` in `audit.jsonl` and removes it from the queue, so it doesn't stay stuck forever (counts toward `failed`);
408
+ - **retryable failure** → left in the queue for the next call (counts toward `still_pending`).
409
+
410
+ ---
411
+
412
+ ## Roadmap
413
+
414
+ `rust-py-audit` is mature (v0.3.0): the SHA-256 hash chain, JSONL storage,
415
+ FastAPI/Django middlewares, and the ImmutableLog `local`/`remote`/`hybrid`
416
+ integration are shipped and stable. Directional ideas under consideration (log
417
+ rotation, incremental verification, more delivery backends, an async client, a
418
+ Flask middleware) are tracked in [ROADMAP.md](./ROADMAP.md).
401
419
 
402
420
  ---
403
421
 
@@ -161,7 +161,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
161
161
  |---|---|---|---|
162
162
  | `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
163
163
  | `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
164
- | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — it becomes `delivery_status="pending"` |
164
+ | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — a transient (retryable) failure becomes `status="pending"` (queued for `flush_pending()`), a permanent one becomes `status="failed"` (not queued) |
165
165
 
166
166
  `mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
167
167
 
@@ -191,11 +191,12 @@ event = audit.log(
191
191
 
192
192
  print(event["immutablelog"])
193
193
  # {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
194
- # or {"status": "pending", "tx_id": None, ...} if delivery failed (hybrid mode)
194
+ # or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
195
+ # or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)
195
196
 
196
197
  # Retries delivery of every event still marked "pending":
197
198
  print(audit.flush_pending())
198
- # {"flushed": 1, "still_pending": 0, "total": 1}
199
+ # {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
199
200
  ```
200
201
 
201
202
  ### Environment variables
@@ -282,7 +283,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
282
283
  - **Retryable**: `5xx` and timeouts.
283
284
  - **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
284
285
  - In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
285
- - In `mode="hybrid"`, the same scenario marks the event as `delivery_status="pending"`, writes it to `audit.pending.jsonl`, and never raises — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later.
286
+ - In `mode="hybrid"`, delivery never raises:
287
+ - a **retryable** failure (5xx/timeout) marks the event `status="pending"` and queues it in `audit.pending.jsonl` — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later;
288
+ - a **permanent** failure (4xx) marks the event `status="failed"` and does **not** queue it (retrying would never succeed, so it stays out of the queue instead of getting stuck there forever). The event is still recorded locally and the chain stays valid — `"failed"` is just operational metadata.
289
+
290
+ In `mode="hybrid"`, the event is appended to the local JSONL **once**, already carrying its final receipt (an O(1) append per event, not a full-file rewrite). A network failure still records the event locally (as `pending`/`failed`), so it is never lost to a failed delivery; the only loss window is the process being killed mid-request, and even then a delivery that did reach ImmutableLog is preserved in the remote store.
286
291
 
287
292
  ### Integrity guarantee
288
293
 
@@ -334,7 +339,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
334
339
  | `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
335
340
  | `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
336
341
  | `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
337
- | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"` or `"pending"`; other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
342
+ | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"`, `"pending"` (transient failure, queued), or `"failed"` (permanent failure, not queued); other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
338
343
 
339
344
  In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
340
345
 
@@ -357,10 +362,23 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
357
362
  Attempts to redeliver to ImmutableLog every event marked as `pending` (recorded in `audit.pending.jsonl`, derived from `file_path`). Only relevant in `mode="hybrid"` — other modes never populate this queue.
358
363
 
359
364
  ```python
360
- {"flushed": 1, "still_pending": 0, "total": 1}
365
+ {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
361
366
  ```
362
367
 
363
- On success, updates `event["immutablelog"]` in `audit.jsonl` (without changing `hash`) and removes the event from the queue. Events that fail again stay in the queue for the next call.
368
+ For each queued event:
369
+ - **delivered** → updates `event["immutablelog"]` in `audit.jsonl` to `"delivered"` (without changing `hash`) and removes it from the queue (counts toward `flushed`);
370
+ - **permanent failure** → marks it `"failed"` in `audit.jsonl` and removes it from the queue, so it doesn't stay stuck forever (counts toward `failed`);
371
+ - **retryable failure** → left in the queue for the next call (counts toward `still_pending`).
372
+
373
+ ---
374
+
375
+ ## Roadmap
376
+
377
+ `rust-py-audit` is mature (v0.3.0): the SHA-256 hash chain, JSONL storage,
378
+ FastAPI/Django middlewares, and the ImmutableLog `local`/`remote`/`hybrid`
379
+ integration are shipped and stable. Directional ideas under consideration (log
380
+ rotation, incremental verification, more delivery backends, an async client, a
381
+ Flask middleware) are tracked in [ROADMAP.md](./ROADMAP.md).
364
382
 
365
383
  ---
366
384
 
@@ -0,0 +1,68 @@
1
+ # Roadmap — rust-py-audit
2
+
3
+ Direction for `rust-py-audit`: a tamper-evident audit log for Python with a Rust
4
+ core (PyO3 + maturin). The library is already mature (v0.3.0) — the SHA-256 hash
5
+ chain, JSONL storage, FastAPI/Django middlewares, and the ImmutableLog
6
+ `local`/`remote`/`hybrid` integration are shipped and stable. This document tracks
7
+ what is done and the **directional** ideas under consideration.
8
+
9
+ > Status legend: ✅ shipped · 🔜 planned (next) · 💡 idea (no version yet) · ⚠️ note
10
+
11
+ > ⚠️ Beyond "shipped", items below are **inferred directions, not commitments**.
12
+ > Confirm priorities with the maintainer before planning a release around them.
13
+
14
+ ---
15
+
16
+ ## Shipped — up to v0.3.0
17
+
18
+ - ✅ `AuditLogger` with `log(...)`, `verify()`, `last_hash()`.
19
+ - ✅ **SHA-256 hash chain** — each event embeds the previous event's hash; any
20
+ edit, deletion, or reordering is detected by `verify()` (re-read from disk,
21
+ never trusts an in-memory cache).
22
+ - ✅ Append-only **JSONL** storage — one event per line, no database.
23
+ - ✅ Thread-safe — the chain stays linear under concurrency.
24
+ - ✅ Free-form JSON-serializable `metadata`.
25
+ - ✅ FastAPI and Django middlewares (log state-changing requests:
26
+ POST/PUT/PATCH/DELETE).
27
+ - ✅ **ImmutableLog integration** — `local`/`remote`/`hybrid` modes, automatic
28
+ retry with idempotency key, pending queue (`audit.pending.jsonl`),
29
+ `flush_pending()`, and transient (`pending`) vs permanent (`failed`) failure
30
+ handling that never takes down the application response.
31
+ - ✅ `severity`, `immutable_trail`, and `env` delivery metadata (never enter the
32
+ hash, so `verify()` stays valid after `flush_pending()`).
33
+ - ✅ ImmutableLog HTTP client runs in the Rust core (PyO3 + reqwest).
34
+
35
+ ---
36
+
37
+ ## Directional ideas (no version assigned — confirm before planning)
38
+
39
+ - 💡 **Log rotation / segmentation** — roll the JSONL by size or date while
40
+ preserving chain continuity across segments (carry the last hash forward).
41
+ - 💡 **Incremental verification** — verify only events appended since the last
42
+ known-good hash, instead of re-reading the whole file every time.
43
+ - 💡 **More delivery backends** — additional sinks alongside ImmutableLog (e.g.
44
+ S3/object storage, syslog, a generic webhook), reusing the retry/pending logic.
45
+ - 💡 **Async client** — non-blocking delivery path for high-throughput async apps.
46
+ - 💡 **Flask middleware** — parity with the FastAPI/Django adapters.
47
+ - 💡 **Query/read helpers** — iterate or filter recorded events (by actor, action,
48
+ resource, time range) without hand-parsing the JSONL.
49
+ - 💡 **Benchmarks suite** — hashing + append throughput under concurrency.
50
+
51
+ ---
52
+
53
+ ## Known limitations (by design, for now)
54
+
55
+ - Local JSONL is single-file and append-only (no built-in rotation yet).
56
+ - `verify()` re-reads the entire file (O(n)); fine for typical logs, see the
57
+ incremental-verification idea above for very large files.
58
+ - ImmutableLog delivery is synchronous on the `log()` path (bounded by
59
+ `timeout_ms` + retries); `hybrid` mode never raises, queuing transient failures.
60
+
61
+ ---
62
+
63
+ ## Contributing to the roadmap
64
+
65
+ Versions and ordering are indicative and may shift. Bump the version in **both**
66
+ `Cargo.toml` and `pyproject.toml` (kept in sync) plus `__version__`, ship tests
67
+ (`cargo test --no-default-features` + `pytest`), then tag `vX.Y.Z` to trigger the
68
+ release workflow (Trusted Publishing / OIDC to PyPI).
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rust-py-audit"
7
- version = "0.2.4"
7
+ version = "0.3.1"
8
8
  description = "Fast, tamper-evident event auditing (hash chain) for Python apps, with a Rust core."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,5 +8,5 @@ que existe um módulo nativo por trás.
8
8
 
9
9
  from ._rust_py_audit import AuditLogger, hello
10
10
 
11
- __version__ = "0.2.4"
11
+ __version__ = "0.3.1"
12
12
  __all__ = ["AuditLogger", "hello"]
@@ -354,38 +354,54 @@ impl AuditLogger {
354
354
 
355
355
  // Igual a `log()`: tudo que trava o `Mutex` ou faz rede roda com
356
356
  // o GIL liberado (REGRA CRÍTICA na doc da struct).
357
- let (flushed, still_pending) = py.allow_threads(|| -> Result<(usize, usize), crate::errors::AuditError> {
358
- // Primeiro a parte cara/lenta (rede), SEM segurar o lock:
359
- // acumulamos os receipts entregues e os ids a remover da fila.
360
- let mut delivered: HashMap<String, ImmutableLogReceipt> = HashMap::new();
361
- let mut flushed_ids: HashSet<String> = HashSet::new();
362
-
363
- for event in &pending_events {
364
- let severity = resolve_severity(event.severity.as_deref())
365
- .map_err(crate::errors::AuditError::Config)?;
366
- if let Ok(receipt) = self.deliver(event, severity, event.immutable_trail.as_deref())
367
- {
368
- delivered.insert(event.id.clone(), receipt);
369
- flushed_ids.insert(event.id.clone());
357
+ let (flushed, failed, still_pending) =
358
+ py.allow_threads(|| -> Result<(usize, usize, usize), crate::errors::AuditError> {
359
+ // Primeiro a parte cara/lenta (rede), SEM segurar o lock:
360
+ // acumulamos os receipts a gravar e os ids a remover da fila.
361
+ // `receipts` junta entregues e permanentes-falhados (ambos
362
+ // saem da fila); `handled` é o conjunto de ids a remover.
363
+ let mut receipts: HashMap<String, ImmutableLogReceipt> = HashMap::new();
364
+ let mut handled: HashSet<String> = HashSet::new();
365
+ let mut flushed = 0usize;
366
+ let mut failed = 0usize;
367
+
368
+ for event in &pending_events {
369
+ let severity = resolve_severity(event.severity.as_deref())
370
+ .map_err(crate::errors::AuditError::Config)?;
371
+ match self.deliver(event, severity, event.immutable_trail.as_deref()) {
372
+ Ok(receipt) => {
373
+ receipts.insert(event.id.clone(), receipt);
374
+ handled.insert(event.id.clone());
375
+ flushed += 1;
376
+ }
377
+ Err(err) if err.is_retryable() => {
378
+ // Transitório: continua na fila para a próxima
379
+ // chamada de `flush_pending()` tentar de novo.
380
+ }
381
+ Err(_) => {
382
+ // Permanente: marca `failed` e tira da fila —
383
+ // retentar não muda o resultado (#8).
384
+ receipts.insert(event.id.clone(), ImmutableLogReceipt::failed());
385
+ handled.insert(event.id.clone());
386
+ failed += 1;
387
+ }
388
+ }
370
389
  }
371
- // Em falha o evento simplesmente continua na fila para a
372
- // próxima chamada de `flush_pending()` tentar de novo.
373
- }
374
390
 
375
- // Só agora travamos, para a parte que mexe nos arquivos —
376
- // serializando contra um `log()` concorrente. As funções em
377
- // lote releem os arquivos aqui dentro, então eventos
378
- // acrescentados nesse meio-tempo são preservados.
379
- let _guard = self.lock_last_hash();
380
- storage::update_event_receipts(&self.file_path, &delivered)?;
381
- storage::remove_events(&pending_path, &flushed_ids)?;
391
+ // Só agora travamos, para a parte que mexe nos arquivos —
392
+ // serializando contra um `log()` concorrente. As funções em
393
+ // lote releem os arquivos aqui dentro, então eventos
394
+ // acrescentados nesse meio-tempo são preservados.
395
+ let _guard = self.lock_last_hash();
396
+ storage::update_event_receipts(&self.file_path, &receipts)?;
397
+ storage::remove_events(&pending_path, &handled)?;
382
398
 
383
- let flushed = flushed_ids.len();
384
- Ok((flushed, total - flushed))
385
- })?;
399
+ Ok((flushed, failed, total - flushed - failed))
400
+ })?;
386
401
 
387
402
  let dict = PyDict::new_bound(py);
388
403
  dict.set_item("flushed", flushed)?;
404
+ dict.set_item("failed", failed)?;
389
405
  dict.set_item("still_pending", still_pending)?;
390
406
  dict.set_item("total", total)?;
391
407
  Ok(dict.into())
@@ -499,29 +515,47 @@ impl AuditLogger {
499
515
  *guard = Some(event.hash.clone());
500
516
  }
501
517
  AuditMode::Hybrid => {
502
- // Salva local PRIMEIRO: mesmo que o processo morra antes
503
- // do envio terminar, o evento já está persistido (sem
504
- // receipt) — nunca perdemos o registro local por uma
505
- // falha de rede.
506
- storage::append_event(&self.file_path, &event)?;
507
- *guard = Some(event.hash.clone());
518
+ // Entrega ANTES de gravar, para anexar o evento ao JSONL
519
+ // local UMA única vez (append O(1)) já com o receipt
520
+ // final — em vez de gravar e depois reescrever o arquivo
521
+ // inteiro (O(n)) só para anexar o receipt. Falha em hybrid
522
+ // NUNCA é exceção Python.
523
+ //
524
+ // Isso preserva a garantia "uma falha de REDE nunca perde
525
+ // o registro local": uma falha de rede (timeout, conexão
526
+ // recusada, 5xx) cai num dos ramos `Err` abaixo, que grava
527
+ // o evento localmente do mesmo jeito. O único cenário novo
528
+ // de perda é o processo ser MORTO no meio da chamada de
529
+ // rede (janela limitada por `timeout_ms`) — e, mesmo aí, se
530
+ // a entrega chegou ao ImmutableLog o registro existe no
531
+ // destino remoto (a fonte de verdade no modo hybrid).
532
+ let (receipt, queue_for_retry) =
533
+ match self.deliver(&event, resolved_severity, event.immutable_trail.as_deref()) {
534
+ Ok(receipt) => (receipt, false),
535
+ Err(err) if err.is_retryable() => {
536
+ // Transitório: vira `pending` e entra na fila
537
+ // para `flush_pending()` tentar de novo depois.
538
+ (ImmutableLogReceipt::pending(), true)
539
+ }
540
+ Err(_) => {
541
+ // Permanente (400/401/403/429/...): retentar não
542
+ // muda nada, então `failed` e NÃO enfileira.
543
+ (ImmutableLogReceipt::failed(), false)
544
+ }
545
+ };
508
546
 
509
- match self.deliver(&event, resolved_severity, event.immutable_trail.as_deref()) {
510
- Ok(receipt) => {
511
- event.immutablelog = Some(receipt.clone());
512
- storage::update_event_receipt(&self.file_path, &event.id, &receipt)?;
513
- }
514
- Err(_) => {
515
- // Falha em hybrid NÃO é exceção Python: o evento
516
- // já está seguro localmente, então devolvemos
517
- // status "pending" e deixamos `flush_pending()`
518
- // tentar de novo depois.
519
- let pending_receipt = ImmutableLogReceipt::pending();
520
- event.immutablelog = Some(pending_receipt.clone());
521
- storage::update_event_receipt(&self.file_path, &event.id, &pending_receipt)?;
522
- storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
523
- }
547
+ event.immutablelog = Some(receipt);
548
+
549
+ // Enfileira ANTES de gravar no arquivo principal: se o
550
+ // processo morrer entre os dois appends, o evento ainda
551
+ // está na fila e `flush_pending()` o reentrega — preferível
552
+ // a ficar gravado como `pending` no arquivo principal sem
553
+ // estar na fila (preso sem reenvio).
554
+ if queue_for_retry {
555
+ storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
524
556
  }
557
+ storage::append_event(&self.file_path, &event)?;
558
+ *guard = Some(event.hash.clone());
525
559
  }
526
560
  }
527
561
 
@@ -39,4 +39,16 @@ impl ImmutableLogReceipt {
39
39
  ..Default::default()
40
40
  }
41
41
  }
42
+
43
+ /// Entrega abandonada por erro PERMANENTE (400/401/403/429/...):
44
+ /// diferente de `pending`, um evento `failed` NÃO entra na fila de
45
+ /// reenvio — retentar não mudaria o resultado, então deixá-lo
46
+ /// pendente só o prenderia para sempre numa fila que
47
+ /// `flush_pending()` nunca conseguiria esvaziar.
48
+ pub fn failed() -> Self {
49
+ ImmutableLogReceipt {
50
+ status: "failed".to_string(),
51
+ ..Default::default()
52
+ }
53
+ }
42
54
  }
@@ -14,7 +14,7 @@ def test_flush_pending_on_empty_queue_is_a_no_op(tmp_path, immutablelog_server):
14
14
 
15
15
  result = audit.flush_pending()
16
16
 
17
- assert result == {"flushed": 0, "still_pending": 0, "total": 0}
17
+ assert result == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
18
18
 
19
19
 
20
20
  def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutablelog_server):
@@ -32,7 +32,7 @@ def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutab
32
32
  assert event["immutablelog"]["status"] == "pending"
33
33
 
34
34
  still_failing = audit.flush_pending()
35
- assert still_failing == {"flushed": 0, "still_pending": 1, "total": 1}
35
+ assert still_failing == {"flushed": 0, "failed": 0, "still_pending": 1, "total": 1}
36
36
 
37
37
  immutablelog_server.set_default_response(202, {
38
38
  "ok": True,
@@ -43,7 +43,7 @@ def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutab
43
43
  "request_id": "req_flushed",
44
44
  })
45
45
  recovered = audit.flush_pending()
46
- assert recovered == {"flushed": 1, "still_pending": 0, "total": 1}
46
+ assert recovered == {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
47
47
 
48
48
  pending_path = tmp_path / "audit.pending.jsonl"
49
49
  assert pending_path.read_text().strip() == ""
@@ -123,4 +123,37 @@ def test_flush_pending_without_pending_file_is_a_no_op(tmp_path):
123
123
 
124
124
  result = audit.flush_pending()
125
125
 
126
- assert result == {"flushed": 0, "still_pending": 0, "total": 0}
126
+ assert result == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
127
+
128
+
129
+ def test_flush_pending_gives_up_on_permanent_error_and_dequeues(tmp_path, immutablelog_server):
130
+ # Um evento que ficou "pending" por uma falha transitória (500), mas
131
+ # cuja causa virou PERMANENTE (401) na hora do flush, precisa sair da
132
+ # fila marcado como "failed" — em vez de ficar preso para sempre (#8).
133
+ immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
134
+ audit = AuditLogger(
135
+ app_name="billing-api",
136
+ file_path=str(tmp_path / "audit.jsonl"),
137
+ mode="hybrid",
138
+ immutablelog_url=immutablelog_server.url,
139
+ immutablelog_api_key="iml_live_ok",
140
+ retry_enabled=False,
141
+ )
142
+
143
+ event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
144
+ assert event["immutablelog"]["status"] == "pending"
145
+
146
+ immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
147
+ result = audit.flush_pending()
148
+ assert result == {"flushed": 0, "failed": 1, "still_pending": 0, "total": 1}
149
+
150
+ # Saiu da fila e ficou marcado como "failed" no arquivo principal.
151
+ pending_path = tmp_path / "audit.pending.jsonl"
152
+ assert pending_path.read_text().strip() == ""
153
+
154
+ persisted = json.loads((tmp_path / "audit.jsonl").read_text().strip())
155
+ assert persisted["immutablelog"]["status"] == "failed"
156
+ assert persisted["hash"] == event["hash"]
157
+
158
+ # Uma nova chamada não tem mais nada para tentar.
159
+ assert audit.flush_pending() == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
@@ -39,9 +39,10 @@ def test_hybrid_does_not_raise_on_remote_failure(tmp_path, immutablelog_server):
39
39
  assert event["immutablelog"]["status"] == "pending"
40
40
 
41
41
 
42
- def test_hybrid_failure_keeps_local_event_and_queues_pending_file(tmp_path, immutablelog_server):
43
- immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
44
- audit = _audit_logger(tmp_path, immutablelog_server)
42
+ def test_hybrid_retryable_failure_keeps_local_event_and_queues_pending_file(tmp_path, immutablelog_server):
43
+ # 500 é RETRYABLE: vira "pending" e entra na fila para reenvio.
44
+ immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
45
+ audit = _audit_logger(tmp_path, immutablelog_server, retry_enabled=False)
45
46
 
46
47
  event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
47
48
 
@@ -58,6 +59,30 @@ def test_hybrid_failure_keeps_local_event_and_queues_pending_file(tmp_path, immu
58
59
  assert json.loads(pending_lines[0])["id"] == event["id"]
59
60
 
60
61
 
62
+ def test_hybrid_permanent_failure_marks_failed_and_does_not_queue(tmp_path, immutablelog_server):
63
+ # 401 é PERMANENTE (#8): vira "failed" e NÃO entra na fila — retentar
64
+ # não mudaria o resultado, então não fica preso para sempre.
65
+ immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
66
+ audit = _audit_logger(tmp_path, immutablelog_server)
67
+
68
+ event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
69
+
70
+ assert event["immutablelog"]["status"] == "failed"
71
+
72
+ audit_lines = (tmp_path / "audit.jsonl").read_text().strip().splitlines()
73
+ assert len(audit_lines) == 1
74
+ persisted = json.loads(audit_lines[0])
75
+ assert persisted["hash"] == event["hash"]
76
+ assert persisted["immutablelog"]["status"] == "failed"
77
+
78
+ # Erro permanente não cria/popula a fila de pendências.
79
+ pending_path = tmp_path / "audit.pending.jsonl"
80
+ assert not pending_path.exists() or pending_path.read_text().strip() == ""
81
+
82
+ # E a cadeia local continua válida (o "failed" é só metadado).
83
+ assert audit.verify()["valid"] is True
84
+
85
+
61
86
  def test_hybrid_local_chain_stays_valid_after_failure(tmp_path, immutablelog_server):
62
87
  immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
63
88
  audit = _audit_logger(tmp_path, immutablelog_server, retry_enabled=False)
File without changes
File without changes
File without changes
File without changes