122 lines
4.1 KiB
Python
122 lines
4.1 KiB
Python
"""Local, exclusive report files and durable per-module recovery snapshots."""
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import uuid
|
|
|
|
|
|
def utc_now():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def exclusive_text(path):
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0)
|
|
fd = os.open(path, flags, 0o600)
|
|
return os.fdopen(fd, 'w', encoding='utf-8')
|
|
|
|
|
|
class ReportStore:
|
|
"""JSONL is authoritative for recovery; a final JSON is created on completion.
|
|
|
|
Only complete newline-terminated events are recovery records. No exception
|
|
strings are stored. Callers supply their redactor, including for checkpoints.
|
|
"""
|
|
def __init__(self, destination, redact):
|
|
if destination is None:
|
|
directory = Path('reports')
|
|
directory.mkdir(mode=0o700, exist_ok=True)
|
|
destination = directory / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
|
+ '-' + uuid.uuid4().hex + '.json')
|
|
self.path = Path(destination)
|
|
if os.path.lexists(self.path):
|
|
raise FileExistsError('Report destination exists')
|
|
self.journal_path = self.path.with_name(self.path.name + '.events.jsonl')
|
|
self.redact = redact
|
|
self.stream = exclusive_text(self.journal_path)
|
|
self.failed = False
|
|
self.bytes_written = 0
|
|
|
|
def checkpoint(self, report):
|
|
if self.failed:
|
|
return False
|
|
try:
|
|
event = {'event_version': 1, 'saved_at': utc_now(),
|
|
'snapshot': self.redact(report)}
|
|
body=json.dumps(event, ensure_ascii=False) + '\n'
|
|
size=len(body.encode('utf-8'))
|
|
if size>4*1024**2 or self.bytes_written+size>64*1024**2:
|
|
self.failed=True
|
|
return False
|
|
self.stream.write(body)
|
|
self.stream.flush()
|
|
os.fsync(self.stream.fileno())
|
|
self.bytes_written+=size
|
|
return True
|
|
except (OSError, ValueError, TypeError):
|
|
self.failed = True
|
|
return False
|
|
|
|
def finish(self, report):
|
|
saved = self.checkpoint(report)
|
|
try:
|
|
# Never replace a file created by another process during detection.
|
|
with exclusive_text(self.path) as output:
|
|
json.dump(self.redact(report), output, ensure_ascii=False, indent=2)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
except (OSError, ValueError, TypeError):
|
|
self.failed = True
|
|
return False
|
|
return saved
|
|
|
|
def close(self):
|
|
try:
|
|
self.stream.close()
|
|
except OSError:
|
|
self.failed = True
|
|
|
|
|
|
def recover_latest(path, max_bytes=64*1024**2, max_line_bytes=4*1024**2):
|
|
"""Read the last complete checkpoint, ignoring an interrupted final write.
|
|
|
|
Recovery files are local trusted inputs; this function does not contact any
|
|
service or execute content. A malformed line is not a successful checkpoint.
|
|
"""
|
|
latest = None
|
|
consumed = 0
|
|
with Path(path).open('rb') as stream:
|
|
while True:
|
|
line = stream.readline(max_line_bytes+1)
|
|
if not line:
|
|
break
|
|
consumed += len(line)
|
|
if consumed > max_bytes or len(line)>max_line_bytes:
|
|
raise ValueError('Recovery journal exceeds read budget')
|
|
if not line.endswith(b'\n'):
|
|
break
|
|
try:
|
|
event = json.loads(line)
|
|
if event.get('event_version') == 1 and isinstance(event.get('snapshot'), dict):
|
|
latest = event['snapshot']
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
return latest
|
|
|
|
|
|
class UnavailableStore:
|
|
"""Continue terminal checks when local storage cannot be opened."""
|
|
def __init__(self, destination):
|
|
self.path = Path(destination) if destination else Path('reports')
|
|
self.journal_path = None
|
|
self.failed = True
|
|
|
|
def checkpoint(self, report):
|
|
return False
|
|
|
|
def finish(self, report):
|
|
return False
|
|
|
|
def close(self):
|
|
pass
|