Files
truf-server/app/result_spool.py
T
2026-09-30 20:30:56 +03:00

1074 lines
49 KiB
Python

import contextlib
import hashlib
import json
import logging
import math
import os
import re
import shutil
import stat
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from itertools import islice
from process_identity import current_process_identity
from runtime_security import (
PrivateFileError,
durable_move,
durable_replace,
durable_unlink,
harden_private_file,
is_reparse_point,
private_directory_ready,
private_file_ready,
reject_reparse_components,
require_private_directory,
)
EVENT_ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$')
INTERNAL_TEMP_RE = re.compile(
r'^(?P<final>.+\.json)\.[1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*\.tmp$'
)
RESERVATION_FINAL_RE = re.compile(r'^[0-9a-f]{32}\.json$')
LOCK_FILENAME = '.spool.lock'
QUARANTINE_DIRNAME = 'quarantine'
RESERVATION_DIRNAME = 'reservations'
MAX_DIAGNOSTIC_BYTES = 64 * 1024
MAX_RESERVATION_BYTES = 64 * 1024
DEFAULT_MAX_EVENT_BYTES = 192 * 1024 * 1024
logger = logging.getLogger(__name__)
_PROCESS_LOCKS = {}
_PROCESS_LOCKS_GUARD = threading.Lock()
class ResultSpoolError(RuntimeError):
pass
class SpoolCapacityError(ResultSpoolError):
pass
class SpoolTransientCapacityError(SpoolCapacityError):
def __init__(self, message, state):
self.state = dict(state or {})
super().__init__(message)
class SpoolBlockedError(ResultSpoolError):
pass
class SpoolBackpressureError(SpoolBlockedError):
pass
class SpoolContentionError(ResultSpoolError):
pass
class SpoolCorruptionError(SpoolBlockedError):
pass
class SpoolHashConflictError(SpoolBlockedError):
pass
@dataclass(frozen=True)
class SpoolEvent:
path: str
envelope: dict
@property
def event_id(self):
return self.envelope['scan_event_id']
@property
def event_hash(self):
return self.envelope['scan_event_hash']
def canonical_event_bytes(envelope):
if not isinstance(envelope, dict):
raise ValueError('scan event envelope must be an object')
payload = {key: value for key, value in envelope.items() if key != 'scan_event_hash'}
return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(',', ':'), default=str).encode('utf-8')
def scan_event_hash(envelope):
return hashlib.sha256(canonical_event_bytes(envelope)).hexdigest()
def prepare_scan_event(envelope):
if not isinstance(envelope, dict):
raise ValueError('scan event envelope must be an object')
if envelope.get('version') != 1:
raise ValueError('unsupported scan event envelope version')
prepared = dict(envelope)
event_id = str(prepared.get('scan_event_id') or '')
if not EVENT_ID_RE.fullmatch(event_id):
raise ValueError('scan_event_id has an invalid format')
expected = scan_event_hash(prepared)
supplied = str(prepared.get('scan_event_hash') or '')
if supplied and supplied != expected:
raise SpoolHashConflictError(f'scan event {event_id} has a conflicting payload hash')
prepared['scan_event_hash'] = expected
return prepared
def _serialized_event(envelope):
return json.dumps(envelope, ensure_ascii=True, sort_keys=True, separators=(',', ':'), default=str).encode('utf-8') + b'\n'
def _is_link_or_junction(path):
return is_reparse_point(path)
class ResultSpool:
def __init__(
self,
directory,
max_event_bytes=DEFAULT_MAX_EVENT_BYTES,
max_events=10000,
max_total_bytes=2 * 1024 * 1024 * 1024,
min_free_bytes=1024 * 1024 * 1024,
lock_timeout_sec=30,
):
self.directory = os.path.abspath(os.fspath(directory))
self.quarantine_directory = os.path.join(self.directory, QUARANTINE_DIRNAME)
self.reservation_directory = os.path.join(self.directory, RESERVATION_DIRNAME)
self.max_event_bytes = int(max_event_bytes)
self.max_events = int(max_events)
self.max_total_bytes = int(max_total_bytes)
self.min_free_bytes = int(min_free_bytes)
if self.max_event_bytes <= 0 or self.max_events <= 0 or self.max_total_bytes < self.max_event_bytes:
raise ValueError('result spool limits must be positive and total bytes must cover one event')
if self.min_free_bytes < 0:
raise ValueError('result spool minimum free bytes cannot be negative')
self.lock_timeout_sec = max(0.1, float(lock_timeout_sec))
with _PROCESS_LOCKS_GUARD:
self._process_lock = _PROCESS_LOCKS.setdefault(os.path.normcase(self.directory), threading.Lock())
require_private_directory(self.directory, create=True)
require_private_directory(self.quarantine_directory, create=True)
require_private_directory(self.reservation_directory, create=True)
self._ensure_lock_file()
@property
def lock_path(self):
return os.path.join(self.directory, LOCK_FILENAME)
def _ensure_lock_file(self):
if os.path.lexists(self.lock_path) and _is_link_or_junction(self.lock_path):
raise PrivateFileError(f'result spool lock cannot be a link: {self.lock_path}')
existed = os.path.exists(self.lock_path)
flags = os.O_RDWR | os.O_CREAT
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
descriptor = os.open(self.lock_path, flags, 0o600)
try:
if os.fstat(descriptor).st_size == 0:
os.write(descriptor, b'0')
os.fsync(descriptor)
finally:
os.close(descriptor)
if existed:
if not private_file_ready(self.lock_path):
raise PrivateFileError(f'result spool lock is not private: {self.lock_path}')
else:
harden_private_file(self.lock_path)
@contextlib.contextmanager
def _locked(self):
self._verify_directories()
if not self._process_lock.acquire(timeout=self.lock_timeout_sec):
raise SpoolContentionError('timed out acquiring in-process result spool lock')
handle = None
try:
handle = open(self.lock_path, 'r+b', buffering=0)
deadline = time.monotonic() + self.lock_timeout_sec
while True:
try:
if os.name == 'nt':
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (OSError, BlockingIOError):
if time.monotonic() >= deadline:
raise SpoolContentionError('timed out acquiring result spool lock')
time.sleep(0.05)
self._recover_internal_temporaries()
yield
finally:
if handle is not None:
try:
if os.name == 'nt':
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError:
pass
handle.close()
self._process_lock.release()
def _verify_directories(self):
if _is_link_or_junction(self.directory) or not private_directory_ready(self.directory):
raise PrivateFileError(f'result spool directory is absent or not private: {self.directory}')
if _is_link_or_junction(self.quarantine_directory) or not private_directory_ready(self.quarantine_directory):
raise PrivateFileError(f'result spool quarantine is absent or not private: {self.quarantine_directory}')
if _is_link_or_junction(self.reservation_directory) or not private_directory_ready(self.reservation_directory):
raise PrivateFileError(f'result spool reservations are absent or not private: {self.reservation_directory}')
if _is_link_or_junction(self.lock_path) or not private_file_ready(self.lock_path):
raise PrivateFileError(f'result spool lock is absent or not private: {self.lock_path}')
def _bounded_entries(self, directory, limit):
entries = []
with os.scandir(directory) as iterator:
for entry in iterator:
if entry.name in ('.', '..'):
continue
entries.append(entry)
if len(entries) > limit:
raise SpoolBlockedError(f'result spool directory exceeds its bounded entry limit: {directory}')
return entries
def _quarantine_entries(self):
return self._bounded_entries(self.quarantine_directory, self.max_events + 1)
@staticmethod
def _valid_event_final_name(name):
return name.endswith('.json') and bool(EVENT_ID_RE.fullmatch(name[:-5]))
@staticmethod
def _valid_quarantine_final_name(name):
for suffix in ('.conflict.json.reason.json', '.conflict.json'):
if name.endswith(suffix):
parts = name[:-len(suffix)].rsplit('.', 2)
return (
len(parts) == 3
and bool(EVENT_ID_RE.fullmatch(parts[0]))
and all(re.fullmatch(r'[1-9][0-9]*', value) for value in parts[1:])
)
suffix = '.quarantined.reason.json'
if name.endswith(suffix):
parts = name[:-len(suffix)].rsplit('.', 2)
return (
len(parts) == 3
and bool(parts[0])
and all(re.fullmatch(r'[1-9][0-9]*', value) for value in parts[1:])
)
return False
def _recover_internal_temporaries(self):
scans = (
# Normal limits plus one crash-left temporary. A writer holds this
# same lock, so at most one internal temporary can be introduced
# between successful recovery passes.
(self.directory, self.max_events + 4, self._valid_event_final_name),
(self.reservation_directory, self.max_events + 2, RESERVATION_FINAL_RE.fullmatch),
(self.quarantine_directory, self.max_events + 2, self._valid_quarantine_final_name),
)
for directory, limit, valid_final_name in scans:
for entry in self._bounded_entries(directory, limit):
match = INTERNAL_TEMP_RE.fullmatch(entry.name)
if not match or not valid_final_name(match.group('final')):
continue
if (
entry.is_symlink()
or _is_link_or_junction(entry.path)
or not entry.is_file(follow_symlinks=False)
or not private_file_ready(entry.path)
):
continue
durable_unlink(entry.path)
def _pending_paths(self):
paths = []
unexpected = []
entries = self._bounded_entries(self.directory, self.max_events + 3)
for entry in entries:
if entry.name in (LOCK_FILENAME, QUARANTINE_DIRNAME, RESERVATION_DIRNAME):
continue
if entry.is_symlink() or _is_link_or_junction(entry.path):
raise SpoolCorruptionError(f'result spool contains a link or reparse point: {entry.path}')
if entry.is_file(follow_symlinks=False) and entry.name.endswith('.json'):
paths.append(entry.path)
else:
unexpected.append(entry.path)
return sorted(paths), sorted(unexpected)
@staticmethod
def _regular_file_size(path):
reject_reparse_components(path)
details = os.stat(path, follow_symlinks=False)
if not stat.S_ISREG(details.st_mode):
raise SpoolBlockedError(f'result spool entry is not a regular file: {path}')
return int(details.st_size)
def _load_reservations(self, recover=True):
now = int(time.time())
records = []
for entry in self._bounded_entries(self.reservation_directory, self.max_events + 1):
if entry.is_symlink() or _is_link_or_junction(entry.path) or not entry.is_file(follow_symlinks=False):
raise SpoolCorruptionError(f'invalid result spool reservation entry: {entry.path}')
if not entry.name.endswith('.json') or not private_file_ready(entry.path):
raise SpoolCorruptionError(f'invalid result spool reservation file: {entry.path}')
size = self._regular_file_size(entry.path)
if size <= 0 or size > MAX_RESERVATION_BYTES:
raise SpoolCorruptionError(f'result spool reservation has an invalid size: {entry.path}')
with open(entry.path, 'rb') as handle:
payload = handle.read(MAX_RESERVATION_BYTES + 1)
try:
record = json.loads(payload.decode('utf-8'))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SpoolCorruptionError(f'invalid result spool reservation: {entry.path}') from exc
if (
not isinstance(record, dict)
or record.get('schema') != 1
or str(record.get('reservation_id') or '') + '.json' != entry.name
or not isinstance(record.get('owner'), str)
or not record.get('owner')
or len(record.get('owner')) > 500
or int(record.get('slots') or 0) < 0
or int(record.get('bytes_per_event') or 0) <= 0
or int(record.get('slots') or 0) > self.max_events
or int(record.get('bytes_per_event') or 0) > self.max_event_bytes
):
raise SpoolCorruptionError(f'invalid result spool reservation metadata: {entry.path}')
identity_values = (
record.get('owner_creation_time'), record.get('owner_executable'),
)
binding_protocol = int(record.get('event_binding_protocol') or 0)
if (
bool(identity_values[0]) != bool(identity_values[1])
or (identity_values[0] and len(str(identity_values[0])) > 200)
or (identity_values[1] and len(str(identity_values[1])) > 32768)
or binding_protocol not in (0, 1)
or (binding_protocol == 1 and not all(identity_values))
):
raise SpoolCorruptionError(f'invalid result spool reservation owner identity: {entry.path}')
claims = record.get('claims') or []
try:
expires_at = float(record.get('expires_at'))
recover_after = float(record.get('recover_after'))
except (TypeError, ValueError) as exc:
raise SpoolCorruptionError(f'invalid result spool reservation lease: {entry.path}') from exc
if (
not isinstance(claims, list)
or len(claims) > int(record['slots'])
or size > 4096 + (len(claims) * 512)
or not math.isfinite(expires_at)
or not math.isfinite(recover_after)
or recover_after < expires_at
or any(
not isinstance(claim, dict)
or claim.get('queue_id') is None
or not claim.get('lease_token')
or (claim.get('event_id') and not EVENT_ID_RE.fullmatch(str(claim.get('event_id'))))
or not isinstance(claim.get('event_id_padding', ''), str)
or len(claim.get('event_id_padding', '')) > 127
or (
binding_protocol == 1
and len(str(claim.get('event_id') or '')) + len(claim.get('event_id_padding', '')) != 127
)
or (claim.get('claim_batch') and str(claim.get('claim_batch')) != str(record.get('reservation_id')))
or (claim.get('lease_owner') and str(claim.get('lease_owner')) != str(record.get('owner')))
for claim in claims
)
):
raise SpoolCorruptionError(f'unbounded result spool reservation metadata: {entry.path}')
if recover and now >= recover_after:
logger.warning('Recovering expired result-spool reservation %s', record['reservation_id'])
durable_unlink(entry.path)
continue
records.append((entry.path, record, size))
return records
def _usage(self):
paths, unexpected = self._pending_paths()
total = 0
for path in paths:
try:
total += self._regular_file_size(path)
except OSError as exc:
raise SpoolBlockedError(f'unable to inspect result spool entry {path}: {exc}') from exc
quarantine = self._quarantine_entries()
quarantine_bytes = 0
for entry in quarantine:
if entry.is_symlink() or _is_link_or_junction(entry.path) or not entry.is_file(follow_symlinks=False):
raise SpoolCorruptionError(f'invalid quarantine entry was left untouched: {entry.path}')
quarantine_bytes += self._regular_file_size(entry.path)
reservations = self._load_reservations(recover=True)
active_reserved_claims = [
claim
for _, record, _ in reservations
for claim in (record.get('claims') or [])
if not claim.get('event_id') or not os.path.isfile(self._event_path(str(claim['event_id'])))
]
unbound_reserved_slots = sum(
max(0, int(record['slots']) - len(record.get('claims') or []))
for _, record, _ in reservations
)
reserved_slots = len(active_reserved_claims) + unbound_reserved_slots
reserved_future_bytes = sum(
(
sum(
1 for claim in (record.get('claims') or [])
if not claim.get('event_id') or not os.path.isfile(self._event_path(str(claim['event_id'])))
)
+ max(0, int(record['slots']) - len(record.get('claims') or []))
) * int(record['bytes_per_event'])
for _, record, _ in reservations
)
reserved_bytes = sum(
size for _, _, size in reservations
) + reserved_future_bytes
return {
'paths': paths,
'unexpected': unexpected,
'pending_count': len(paths),
'pending_bytes': total,
'quarantine_count': len(quarantine),
'quarantine_bytes': quarantine_bytes,
'reservations': reservations,
'reserved_slots': reserved_slots,
'reserved_future_bytes': reserved_future_bytes,
'reserved_bytes': reserved_bytes,
'count': len(paths) + len(quarantine) + len(reservations) + reserved_slots,
'bytes': total + quarantine_bytes + reserved_bytes,
}
def _reservation_future_slots(self, record):
claims = record.get('claims') or []
active_claims = sum(
1 for claim in claims
if not claim.get('event_id') or not os.path.isfile(self._event_path(str(claim['event_id'])))
)
return active_claims + max(0, int(record['slots']) - len(claims))
def _assert_not_blocked(self):
quarantine = self._quarantine_entries()
if quarantine:
raise SpoolBlockedError(
f'result spool quarantine contains {len(quarantine)} item(s): {self.quarantine_directory}'
)
def _check_capacity(
self, additional_bytes=0, additional_events=0, disk_bytes=None, reserved_credit=0,
reserved_event_credit=0, existing_bytes_credit=0, quarantine_source=None,
):
usage = self._usage()
paths = usage['paths']
unexpected = list(usage['unexpected'])
if quarantine_source:
source_key = os.path.normcase(os.path.abspath(quarantine_source))
matching = [path for path in unexpected if os.path.normcase(os.path.abspath(path)) == source_key]
if matching:
source_size = self._regular_file_size(matching[0])
usage['count'] += 1
usage['bytes'] += source_size
unexpected.remove(matching[0])
if unexpected:
path = unexpected[0]
try:
regular = stat.S_ISREG(os.stat(path, follow_symlinks=False).st_mode)
except OSError:
regular = False
if _is_link_or_junction(path) or not regular:
raise SpoolCorruptionError(f'unexpected result spool entry was left untouched: {path}')
self._quarantine_path(path, 'unexpected result spool entry')
raise SpoolCorruptionError(f'unexpected result spool entry quarantined: {path}')
requested_events = max(0, int(additional_events))
requested_bytes = max(0, int(additional_bytes))
if requested_events > self.max_events or requested_bytes > self.max_total_bytes:
raise SpoolCapacityError('result spool reservation request exceeds its configured maximum')
effective_count = usage['count'] - max(0, int(reserved_event_credit)) + requested_events
effective_bytes = usage['bytes'] - max(0, int(existing_bytes_credit)) + requested_bytes
capacity_state = {
'current_bytes': int(usage['bytes']),
'current_count': int(usage['count']),
'reserved_future_bytes': int(usage['reserved_future_bytes']),
'reserved_slots': int(usage['reserved_slots']),
'reservation_count': len(usage['reservations']),
'requested_bytes': requested_bytes,
'requested_events': requested_events,
'max_total_bytes': self.max_total_bytes,
'max_events': self.max_events,
'headroom_bytes': self.max_total_bytes - int(usage['bytes']),
}
try:
free = shutil.disk_usage(self.directory).free
except OSError as exc:
raise SpoolCapacityError(f'unable to inspect result spool free space: {exc}') from exc
required_disk = (
max(0, int(usage['reserved_future_bytes']) - max(0, int(reserved_credit)))
+ max(0, int(additional_bytes if disk_bytes is None else disk_bytes))
)
if free - max(0, required_disk) < self.min_free_bytes:
raise SpoolCapacityError('result spool minimum free-space reserve would be violated')
if effective_count > self.max_events:
if usage['reserved_slots'] > 0:
raise SpoolTransientCapacityError('result spool event capacity is temporarily reserved', capacity_state)
raise SpoolCapacityError('result spool event-count limit reached')
if effective_bytes > self.max_total_bytes:
if usage['reserved_future_bytes'] > 0:
raise SpoolTransientCapacityError('result spool byte capacity is temporarily reserved', capacity_state)
raise SpoolCapacityError('result spool total-byte limit reached')
def assert_claims_allowed(self):
with self._locked():
self._assert_not_blocked()
self._check_capacity()
pending = self._pending_paths()[0]
if pending:
self._read_event(pending[0])
if pending:
raise SpoolBackpressureError(f'result spool still contains {len(pending)} pending event(s)')
return True
def backpressure_state(self):
"""Return bounded metadata while validating the oldest pending event."""
with self._locked():
self._assert_not_blocked()
usage = self._usage()
paths = usage['paths']
oldest_age_sec = 0
if paths:
self._read_event(paths[0])
oldest_mtime = min(os.stat(path, follow_symlinks=False).st_mtime for path in paths)
oldest_age_sec = max(0, int(time.time() - oldest_mtime))
return {
'pending_count': int(usage['pending_count']),
'pending_bytes': int(usage['pending_bytes']),
'oldest_pending_age_sec': oldest_age_sec,
'reservation_count': len(usage['reservations']),
'reserved_slots': int(usage['reserved_slots']),
'reserved_future_bytes': int(usage['reserved_future_bytes']),
'headroom_bytes': self.max_total_bytes - int(usage['bytes']),
}
def reservation_snapshot(self):
"""Return validated reservation metadata for exact DB ownership checks."""
with self._locked():
self._assert_not_blocked()
usage = self._usage()
return {
'reserved_future_bytes': int(usage['reserved_future_bytes']),
'reserved_slots': int(usage['reserved_slots']),
'reservation_count': len(usage['reservations']),
'reservations': [dict(record, claims=[dict(claim) for claim in record.get('claims') or []]) for _, record, _ in usage['reservations']],
}
def _event_path(self, event_id):
return os.path.join(self.directory, f'{event_id}.json')
def _reservation_path(self, reservation_id):
return os.path.join(self.reservation_directory, f'{reservation_id}.json')
def _write_private_bytes(self, final_path, payload):
reject_reparse_components(os.path.dirname(os.path.abspath(final_path)))
if os.path.lexists(final_path):
reject_reparse_components(final_path)
temporary = f'{final_path}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp'
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
descriptor = os.open(temporary, flags, 0o600)
try:
harden_private_file(temporary)
with os.fdopen(descriptor, 'wb') as handle:
descriptor = None
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
if not private_file_ready(temporary):
raise PrivateFileError(f'result spool temporary ACL changed: {temporary}')
durable_replace(temporary, final_path)
if not private_file_ready(final_path):
raise PrivateFileError(f'result spool event ACL changed during replace: {final_path}')
finally:
if descriptor is not None:
os.close(descriptor)
try:
if os.path.lexists(temporary):
reject_reparse_components(temporary)
durable_unlink(temporary)
except OSError:
pass
def _save_reservation(self, record):
payload = _serialized_event(record)
if len(payload) > MAX_RESERVATION_BYTES:
raise SpoolCapacityError('result spool reservation metadata is too large')
self._write_private_bytes(self._reservation_path(record['reservation_id']), payload)
def _replace_reservation(self, path, previous, replacement):
previous_size = self._regular_file_size(path)
previous_future = self._reservation_future_slots(previous) * int(previous['bytes_per_event'])
previous_events = 1 + self._reservation_future_slots(previous)
replacement_payload = _serialized_event(replacement)
if len(replacement_payload) > MAX_RESERVATION_BYTES:
raise SpoolCapacityError('result spool reservation metadata is too large')
replacement_future = self._reservation_future_slots(replacement) * int(replacement['bytes_per_event'])
replacement_events = 1 + self._reservation_future_slots(replacement)
self._check_capacity(
len(replacement_payload) + replacement_future,
replacement_events,
disk_bytes=len(replacement_payload) + replacement_future,
reserved_credit=previous_future,
reserved_event_credit=previous_events,
existing_bytes_credit=previous_size + previous_future,
)
self._write_private_bytes(path, replacement_payload)
def _reservation_record(self, reservation_id):
expected = self._reservation_path(reservation_id)
for path, record, _ in self._load_reservations(recover=True):
if path == expected:
return path, record
raise SpoolCapacityError(f'result spool reservation is absent or expired: {reservation_id}')
def reserve_claims(self, owner, count, lease_seconds, bytes_per_event=None):
count = max(0, int(count or 0))
if not owner or count <= 0:
raise ValueError('claim reservation requires an owner and positive count')
bytes_per_event = int(bytes_per_event or self.max_event_bytes)
if bytes_per_event <= 0 or bytes_per_event > self.max_event_bytes:
raise ValueError('claim reservation per-event bound is invalid')
lease_seconds = max(60, int(lease_seconds or 0))
now = int(time.time())
identity = current_process_identity()
reservation_id = uuid.uuid4().hex
record = {
'schema': 1,
'reservation_id': reservation_id,
'owner': str(owner)[:500],
'owner_pid': os.getpid(),
'owner_creation_time': str(identity.creation_time),
'owner_executable': str(identity.executable),
'event_binding_protocol': 1,
'slots': count,
'bytes_per_event': bytes_per_event,
'claims': [],
'created_at': now,
'expires_at': now + lease_seconds,
'recover_after': now + (lease_seconds * 2),
}
metadata_bytes = len(_serialized_event(record))
with self._locked():
self._assert_not_blocked()
if self._pending_paths()[0]:
raise SpoolBackpressureError('result spool contains pending events; drain them before new claims')
self._check_capacity(
count * bytes_per_event + metadata_bytes,
count + 1,
disk_bytes=count * bytes_per_event + metadata_bytes,
)
self._save_reservation(record)
return reservation_id
def bind_claims(self, reservation_id, claims):
normalized = []
for claim in claims or []:
queue_id = claim.get('id') if isinstance(claim, dict) else claim['id']
lease_token = claim.get('lease_token') if isinstance(claim, dict) else claim['lease_token']
if queue_id is None or not lease_token:
raise ValueError('reserved claim requires queue ID and lease token')
claim_batch = claim.get('claim_batch') if isinstance(claim, dict) else claim['claim_batch'] if 'claim_batch' in claim.keys() else None
lease_owner = claim.get('lease_owner') if isinstance(claim, dict) else claim['lease_owner'] if 'lease_owner' in claim.keys() else None
if claim_batch and str(claim_batch) != str(reservation_id):
raise ValueError('database claim batch does not match its spool reservation')
normalized.append({
'queue_id': int(queue_id), 'lease_token': str(lease_token),
'claim_batch': str(reservation_id), 'lease_owner': str(lease_owner or ''),
'event_id': '', 'event_id_padding': '0' * 127,
})
with self._locked():
path, record = self._reservation_record(reservation_id)
if any(claim.get('lease_owner') and claim['lease_owner'] != record['owner'] for claim in normalized):
raise SpoolCapacityError('database claim owner does not match its spool reservation')
if len(normalized) > int(record['slots']):
raise SpoolCapacityError('database returned more claims than were reserved')
if not normalized:
durable_unlink(path)
return None
previous = dict(record, claims=[dict(claim) for claim in record.get('claims') or []])
record['claims'] = normalized
record['slots'] = len(normalized)
self._replace_reservation(path, previous, record)
return reservation_id
def renew_reservation(self, reservation_id, lease_seconds):
lease_seconds = max(60, int(lease_seconds or 0))
with self._locked():
try:
_, record = self._reservation_record(reservation_id)
except SpoolCapacityError:
return False
now = int(time.time())
previous = dict(record, claims=[dict(claim) for claim in record.get('claims') or []])
record['expires_at'] = now + lease_seconds
record['recover_after'] = now + (lease_seconds * 2)
self._replace_reservation(self._reservation_path(reservation_id), previous, record)
return True
def release_reservation(self, reservation_id):
if not reservation_id:
return False
with self._locked():
path = self._reservation_path(reservation_id)
if not os.path.lexists(path):
return False
reject_reparse_components(path)
durable_unlink(path)
return True
def release_reserved_claim(self, reservation_id, queue_id, lease_token):
if not reservation_id:
return False
with self._locked():
try:
path, record = self._reservation_record(reservation_id)
except SpoolCapacityError:
return False
remaining = [
claim for claim in record.get('claims') or []
if not (
int(claim.get('queue_id') or -1) == int(queue_id)
and str(claim.get('lease_token') or '') == str(lease_token or '')
)
]
if len(remaining) == len(record.get('claims') or []):
return False
if not remaining:
durable_unlink(path)
else:
previous = dict(record, claims=[dict(claim) for claim in record.get('claims') or []])
record['claims'] = remaining
record['slots'] = len(remaining)
self._replace_reservation(path, previous, record)
return True
def stopped_owner_unused_reservations(self, source, process_identity):
"""Return only exact stopped-owner reservations with no durable event."""
source = str(source or '')
with self._locked():
output = []
for _, record, _ in self._load_reservations(recover=True):
if (
int(record.get('event_binding_protocol') or 0) != 1
or str(record.get('owner') or '').split(':', 1)[0] != source
or int(record.get('owner_pid') or 0) != int(process_identity.pid)
or str(record.get('owner_creation_time') or '') != str(process_identity.creation_time)
or os.path.normcase(str(record.get('owner_executable') or ''))
!= os.path.normcase(str(process_identity.executable))
):
continue
claims = [dict(claim) for claim in record.get('claims') or []]
if any(
claim.get('event_id')
and os.path.isfile(self._event_path(str(claim['event_id'])))
for claim in claims
):
continue
output.append(dict(record, claims=claims))
return output
def release_stopped_owner_reservation(self, reservation_id, source, process_identity):
with self._locked():
try:
path, record = self._reservation_record(reservation_id)
except SpoolCapacityError:
return False
if (
int(record.get('event_binding_protocol') or 0) != 1
or str(record.get('owner') or '').split(':', 1)[0] != str(source or '')
or int(record.get('owner_pid') or 0) != int(process_identity.pid)
or str(record.get('owner_creation_time') or '') != str(process_identity.creation_time)
or os.path.normcase(str(record.get('owner_executable') or ''))
!= os.path.normcase(str(process_identity.executable))
or any(
claim.get('event_id')
and os.path.isfile(self._event_path(str(claim['event_id'])))
for claim in record.get('claims') or []
)
):
return False
durable_unlink(path)
return True
def _consume_event_reservations(self, event_id):
for path, record, _ in self._load_reservations(recover=False):
claims = [dict(claim) for claim in record.get('claims') or []]
remaining = [claim for claim in claims if str(claim.get('event_id') or '') != str(event_id)]
if len(remaining) == len(claims):
continue
if remaining:
previous = dict(record, claims=claims)
record['claims'] = remaining
record['slots'] = len(remaining)
self._replace_reservation(path, previous, record)
else:
durable_unlink(path)
@staticmethod
def _quarantine_reason_payload(quarantined_path, reason):
return _serialized_event({
'quarantined_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
'reason': str(reason)[:2000],
'quarantined_file': os.path.basename(quarantined_path),
})
def _quarantine_path(self, path, reason):
reject_reparse_components(path)
details = os.stat(path, follow_symlinks=False)
if not stat.S_ISREG(details.st_mode):
raise SpoolCorruptionError(f'refusing to move non-regular quarantine source: {path}')
name = os.path.basename(path)
destination = os.path.join(
self.quarantine_directory,
f'{name}.{time.time_ns()}.{os.getpid()}.quarantined',
)
reason_path = destination + '.reason.json'
reason_payload = self._quarantine_reason_payload(destination, reason)
self._check_capacity(
len(reason_payload), 1, disk_bytes=len(reason_payload), quarantine_source=path,
)
try:
harden_private_file(path)
except OSError:
pass
durable_move(path, destination)
harden_private_file(destination)
self._write_private_bytes(reason_path, reason_payload)
return destination
def _quarantine_conflicting_envelope(self, envelope, reason):
envelope = envelope if isinstance(envelope, dict) else {}
event_id = str(envelope.get('scan_event_id') or 'unknown')
if not EVENT_ID_RE.fullmatch(event_id):
event_id = 'invalid-' + hashlib.sha256(event_id.encode('utf-8', errors='replace')).hexdigest()[:24]
path = os.path.join(
self.quarantine_directory,
f'{event_id}.{time.time_ns()}.{os.getpid()}.conflict.json',
)
diagnostic = {
'quarantined_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
'reason': str(reason)[:2000],
'scan_event_id': event_id,
'offered_type': type(envelope).__name__,
'offered_keys': sorted(str(key)[:100] for key in islice(envelope, 100)),
'payload_omitted': True,
}
payload = _serialized_event(diagnostic)
if len(payload) > MAX_DIAGNOSTIC_BYTES:
diagnostic['offered_keys'] = []
payload = _serialized_event(diagnostic)
payload = payload[:MAX_DIAGNOSTIC_BYTES]
reason_path = path + '.reason.json'
reason_payload = self._quarantine_reason_payload(path, reason)
self._check_capacity(
len(payload) + len(reason_payload), 2,
disk_bytes=len(payload) + len(reason_payload),
)
try:
self._write_private_bytes(path, payload)
self._write_private_bytes(reason_path, reason_payload)
except Exception:
for created in (reason_path, path):
try:
if os.path.lexists(created) and not _is_link_or_junction(created):
durable_unlink(created)
except OSError:
pass
raise
def write_event(self, envelope, reservation_id=None, queue_id=None, lease_token=None):
try:
prepared = prepare_scan_event(envelope)
except (SpoolHashConflictError, ValueError) as exc:
visible = envelope if isinstance(envelope, dict) else {'invalid_envelope_type': type(envelope).__name__}
with self._locked():
self._assert_not_blocked()
self._quarantine_conflicting_envelope(visible, f'invalid event offered to spool: {exc}')
if isinstance(exc, SpoolHashConflictError):
raise
raise SpoolCorruptionError(str(exc)) from exc
payload = _serialized_event(prepared)
if len(payload) > self.max_event_bytes:
with self._locked():
self._assert_not_blocked()
self._quarantine_conflicting_envelope(
{'scan_event_id': prepared.get('scan_event_id')},
f'offered scan event exceeds {self.max_event_bytes} byte limit; payload omitted',
)
raise SpoolCapacityError('scan event exceeds result spool per-event byte limit')
event_id = prepared['scan_event_id']
final_path = self._event_path(event_id)
with self._locked():
self._assert_not_blocked()
if os.path.lexists(final_path):
reject_reparse_components(final_path)
existing = self._read_event(final_path)
if existing.envelope['scan_event_hash'] == prepared['scan_event_hash']:
return existing
self._quarantine_conflicting_envelope(prepared, 'same event ID has a different payload hash')
raise SpoolHashConflictError(f'scan event {event_id} conflicts with its pending envelope')
reservation_path = None
reservation = None
reserved_bytes = 0
if reservation_id:
reservation_path, reservation = self._reservation_record(reservation_id)
matching = [
claim for claim in reservation.get('claims') or []
if int(claim.get('queue_id') or -1) == int(queue_id if queue_id is not None else -1)
and str(claim.get('lease_token') or '') == str(lease_token or '')
]
if len(matching) != 1:
raise SpoolCapacityError('scan event does not match a live reserved claim')
bound_event_id = matching[0].get('event_id')
if bound_event_id and str(bound_event_id) != event_id:
raise SpoolHashConflictError('reserved claim is already bound to a different scan event')
reserved_bytes = int(reservation['bytes_per_event'])
if len(payload) > reserved_bytes:
raise SpoolCapacityError('scan event exceeds its reserved per-event byte limit')
previous = dict(reservation, claims=[dict(claim) for claim in reservation.get('claims') or []])
reservation['claims'] = [
dict(
claim,
event_id=event_id,
event_id_padding='0' * (127 - len(event_id)),
) if claim is matching[0] else dict(claim)
for claim in reservation.get('claims') or []
]
self._replace_reservation(reservation_path, previous, reservation)
self._check_capacity(
len(payload), 1,
disk_bytes=len(payload), reserved_credit=reserved_bytes,
reserved_event_credit=1,
existing_bytes_credit=reserved_bytes,
)
else:
self._check_capacity(len(payload), 1, disk_bytes=len(payload))
self._write_private_bytes(final_path, payload)
record = self._read_event(final_path)
if reservation is not None:
remaining = [
claim for claim in reservation.get('claims') or []
if not (
int(claim.get('queue_id') or -1) == int(queue_id)
and str(claim.get('lease_token') or '') == str(lease_token or '')
)
]
try:
if remaining:
previous = dict(reservation, claims=[dict(claim) for claim in reservation.get('claims') or []])
reservation['claims'] = remaining
reservation['slots'] = len(remaining)
self._replace_reservation(reservation_path, previous, reservation)
else:
durable_unlink(reservation_path)
except Exception as exc:
logger.critical(
'Result event is durable but its conservative spool reservation could not be consumed: %s',
exc,
)
return record
def _read_event(self, path):
reject_reparse_components(path)
if not private_file_ready(path):
try:
self._quarantine_path(path, 'event file ACL is not private')
finally:
raise SpoolCorruptionError(f'non-private result spool event quarantined: {path}')
try:
size = self._regular_file_size(path)
if size <= 0 or size > self.max_event_bytes:
raise ValueError('event file has an invalid size')
with open(path, 'rb') as handle:
payload = handle.read(self.max_event_bytes + 1)
if len(payload) > self.max_event_bytes:
raise ValueError('event file exceeds the per-event limit')
envelope = json.loads(payload.decode('utf-8'))
prepared = prepare_scan_event(envelope)
expected_name = f'{prepared["scan_event_id"]}.json'
if os.path.basename(path) != expected_name:
raise ValueError('event filename does not match scan_event_id')
if envelope.get('scan_event_hash') != prepared['scan_event_hash']:
raise ValueError('event payload hash does not match')
return SpoolEvent(path, prepared)
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError, SpoolHashConflictError) as exc:
if os.path.lexists(path) and not _is_link_or_junction(path):
self._quarantine_path(path, f'malformed result spool event: {exc}')
raise SpoolCorruptionError(f'malformed result spool event quarantined: {path}: {exc}') from exc
def pending_events(self, limit=1):
limit = 1
with self._locked():
self._assert_not_blocked()
usage = self._usage()
paths = usage['paths']
unexpected = usage['unexpected']
if unexpected:
path = unexpected[0]
try:
regular = stat.S_ISREG(os.stat(path, follow_symlinks=False).st_mode)
except OSError:
regular = False
if _is_link_or_junction(path) or not regular:
raise SpoolCorruptionError(f'unexpected result spool entry was left untouched: {path}')
self._quarantine_path(path, 'unexpected result spool entry')
raise SpoolCorruptionError(f'unexpected result spool entry quarantined: {path}')
return [self._read_event(path) for path in paths[:limit]]
def next_pending_event(self):
records = self.pending_events(limit=1)
return records[0] if records else None
def acknowledge(self, event_id, event_hash):
if not EVENT_ID_RE.fullmatch(str(event_id or '')):
raise ValueError('scan_event_id has an invalid format')
with self._locked():
path = self._event_path(event_id)
if not os.path.lexists(path):
return False
reject_reparse_components(path)
record = self._read_event(path)
if record.event_hash != event_hash:
self._quarantine_path(path, 'acknowledgement hash conflicts with pending event')
raise SpoolHashConflictError(f'acknowledgement hash conflicts for scan event {event_id}')
durable_unlink(path)
self._consume_event_reservations(event_id)
return True
def quarantine_event(self, event_id, reason):
with self._locked():
path = self._event_path(event_id)
if not os.path.lexists(path):
return None
reject_reparse_components(path)
return self._quarantine_path(path, reason)
def quarantine_payload(self, envelope, reason):
prepared = dict(envelope or {})
with self._locked():
self._quarantine_conflicting_envelope(prepared, reason)
raise SpoolHashConflictError(str(reason))