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

1397 lines
61 KiB
Python

import hashlib
import json
import math
import os
import re
import stat
import threading
import time
from collections import deque
from datetime import datetime, timezone
from runtime_security import (
atomic_write_private_json,
durable_replace,
durable_unlink,
ensure_private_directory,
fsync_directory,
harden_private_file,
private_file_ready,
read_private_json,
reject_reparse_components,
require_private_directory,
)
from worker_contracts import (
WORKER_EVENT_SCHEMA,
WORKER_EVENT_TYPE,
PROGRESS_OUTBOX_RELATIVE_PATH,
PROGRESS_OUTBOX_SCHEMA,
WorkerEvent,
WorkerPhase,
decode_diagnostic_envelope,
decode_worker_event,
diagnostic_material_bytes,
encode_diagnostic_envelope,
encode_worker_event,
validate_utc_timestamp,
validate_phase_transition,
)
STATUS_SCHEMA = 1
HISTORY_SCHEMA = 2
LEGACY_HISTORY_SCHEMA = 1
DIAGNOSTIC_ARCHIVE_SCHEMA = 1
RETENTION_SCHEMA = 1
MAX_EVENT_LINE_BYTES = 256 * 1024
MAX_HISTORY_LINE_BYTES = 2 * 1024 * 1024
MAX_LOG_LINE_BYTES = 256 * 1024
DEFAULT_LOG_BYTES = 2 * 1024 * 1024
DEFAULT_LOG_FILES = 5
DEFAULT_RETENTION_DAYS = 30
DEFAULT_RETENTION_BYTES = 1024 * 1024 * 1024
DEFAULT_EVENT_SEGMENT_BYTES = 8 * 1024 * 1024
DEFAULT_HISTORY_SEGMENT_BYTES = 8 * 1024 * 1024
EVENT_CURSOR_STRIDE = 128
_EVENT_SEGMENT_RE = re.compile(
r'^worker-events\.(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.jsonl$'
)
_HISTORY_SEGMENT_RE = re.compile(r'^worker-history\.(0|[1-9][0-9]*)\.jsonl$')
_HISTORY_FIELDS = frozenset({
'schema', 'history_id', 'instance_id', 'slot_id', 'reservation_id',
'source', 'outcome', 'receipt', 'started_at', 'completed_at',
'duration_seconds', 'first_sequence', 'last_sequence', 'diagnostics',
'timeline', 'phase_durations',
})
_TIMELINE_FIELDS = frozenset({
'sequence', 'timestamp', 'instance_id', 'phase', 'progress',
})
_LEGACY_TIMELINE_FIELDS = frozenset({'sequence', 'timestamp', 'phase'})
_DIAGNOSTIC_REFERENCE_FIELDS = frozenset({
'diagnostic_uid', 'record', 'artifacts',
})
_ARTIFACT_FIELDS = frozenset({
'path', 'sha256', 'original_sha256', 'size', 'original_size', 'encoding',
'truncated',
})
_SHA256 = re.compile(r'^[0-9a-f]{64}$')
class WorkerLocalStateError(RuntimeError):
pass
def _progress_cursor_value(path):
value = read_private_json(path, max_bytes=64 * 1024)
if (
not isinstance(value, dict)
or set(value) != {'schema', 'sequence'}
or value.get('schema') != PROGRESS_OUTBOX_SCHEMA
or type(value.get('sequence')) is not int
or value['sequence'] < 0
):
raise WorkerLocalStateError('progress outbox cursor is invalid')
return value['sequence']
def prepare_progress_outbox_cursor(state_root, *, create=True):
root = os.path.abspath(state_root)
ensure_private_directory(
os.path.join(root, 'control'), reject_reparse=True,
)
old_path = os.path.join(root, 'progress-outbox.json')
path = os.path.join(root, *PROGRESS_OUTBOX_RELATIVE_PATH.split('/'))
old_exists = os.path.exists(old_path)
new_exists = os.path.exists(path)
old_sequence = _progress_cursor_value(old_path) if old_exists else None
new_sequence = _progress_cursor_value(path) if new_exists else None
if old_exists and new_exists:
if old_sequence != new_sequence:
raise WorkerLocalStateError(
'conflicting progress outbox cursors require operator recovery'
)
durable_unlink(old_path)
return path, new_sequence
if old_exists:
atomic_write_private_json(path, {
'schema': PROGRESS_OUTBOX_SCHEMA,
'sequence': old_sequence,
}, max_bytes=64 * 1024)
durable_unlink(old_path)
return path, old_sequence
if new_exists:
return path, new_sequence
if create:
atomic_write_private_json(path, {
'schema': PROGRESS_OUTBOX_SCHEMA,
'sequence': 0,
}, max_bytes=64 * 1024)
return path, 0
return path, None
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
def _canonical_json(value):
return json.dumps(
value, ensure_ascii=True, sort_keys=True, separators=(',', ':'),
allow_nan=False,
).encode('ascii')
def _append_private(path, payload, maximum):
if type(payload) is not bytes or not payload.endswith(b'\n') or len(payload) > maximum:
raise WorkerLocalStateError('append payload is invalid')
parent = ensure_private_directory(os.path.dirname(os.path.abspath(path)), reject_reparse=True)
existed = os.path.lexists(path)
if existed:
reject_reparse_components(path)
if not private_file_ready(path):
raise WorkerLocalStateError('append target is not private')
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
descriptor = os.open(path, flags, 0o600)
try:
if not existed:
harden_private_file(path)
with os.fdopen(descriptor, 'ab', buffering=0) as handle:
descriptor = None
handle.write(payload)
os.fsync(handle.fileno())
fsync_directory(parent)
finally:
if descriptor is not None:
os.close(descriptor)
def _write_private_bytes(path, payload, maximum=64 * 1024 * 1024):
if type(payload) is not bytes or len(payload) > maximum:
raise WorkerLocalStateError('artifact payload is invalid')
parent = ensure_private_directory(os.path.dirname(os.path.abspath(path)), reject_reparse=True)
temporary = f'{path}.{os.getpid()}.{threading.get_ident()}.tmp'
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, 'wb') as handle:
descriptor = None
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
harden_private_file(temporary)
durable_replace(temporary, path)
finally:
if descriptor is not None:
os.close(descriptor)
try:
if os.path.exists(temporary):
os.remove(temporary)
except OSError:
pass
if not private_file_ready(path):
raise WorkerLocalStateError('artifact is not private after publication')
fsync_directory(parent)
def _event_value(event):
return json.loads(encode_worker_event(event).decode('ascii'))
def _relative_path(value, field):
if not isinstance(value, str) or not value or '\\' in value or value.startswith('/'):
raise WorkerLocalStateError(f'history {field} is invalid')
if any(part in ('', '.', '..') for part in value.split('/')):
raise WorkerLocalStateError(f'history {field} is invalid')
return value
def _history_timestamp(value, field, optional=False):
if optional and value is None:
return None
try:
return validate_utc_timestamp(value, field)
except ValueError as exc:
raise WorkerLocalStateError(f'history {field} is invalid') from exc
def validate_history_record(value):
if not isinstance(value, dict):
raise WorkerLocalStateError('terminal history shape is invalid')
schema = value.get('schema')
legacy_without_instance = _HISTORY_FIELDS - {'instance_id'}
fields = frozenset(value)
if schema == LEGACY_HISTORY_SCHEMA and fields in {
_HISTORY_FIELDS, legacy_without_instance,
}:
legacy = True
value = dict(value)
value.setdefault('instance_id', 'legacy-instance-unavailable')
elif schema == HISTORY_SCHEMA and fields == _HISTORY_FIELDS:
legacy = False
value = dict(value)
else:
raise WorkerLocalStateError('terminal history shape is invalid')
if type(schema) is not int:
raise WorkerLocalStateError('terminal history schema is invalid')
for field in ('history_id', 'instance_id', 'outcome'):
if not isinstance(value.get(field), str) or not value[field] or len(value[field]) > 256:
raise WorkerLocalStateError(f'terminal history {field} is invalid')
if type(value.get('slot_id')) is not int or value['slot_id'] < 0:
raise WorkerLocalStateError('terminal history slot identity is invalid')
if type(value.get('reservation_id')) is not int or value['reservation_id'] <= 0:
raise WorkerLocalStateError('terminal history reservation identity is invalid')
source = value.get('source')
if source is not None and (not isinstance(source, str) or not source):
raise WorkerLocalStateError('terminal history source is invalid')
if not isinstance(value.get('receipt'), dict):
raise WorkerLocalStateError('terminal history receipt is invalid')
try:
_canonical_json(value['receipt'])
except (TypeError, ValueError) as exc:
raise WorkerLocalStateError('terminal history receipt is invalid') from exc
started_at = _history_timestamp(value.get('started_at'), 'started_at', optional=True)
completed_at = _history_timestamp(value.get('completed_at'), 'completed_at')
duration = value.get('duration_seconds')
if duration is not None and (
type(duration) not in (int, float) or not math.isfinite(duration) or duration < 0
):
raise WorkerLocalStateError('terminal history duration is invalid')
first = value.get('first_sequence')
last = value.get('last_sequence')
if first is not None and (type(first) is not int or first <= 0):
raise WorkerLocalStateError('terminal history first sequence is invalid')
if last is not None and (type(last) is not int or last <= 0):
raise WorkerLocalStateError('terminal history last sequence is invalid')
if first is not None and last is not None and last < first:
raise WorkerLocalStateError('terminal history sequence range is invalid')
diagnostics = value.get('diagnostics')
if not isinstance(diagnostics, list) or len(diagnostics) > 32:
raise WorkerLocalStateError('terminal history diagnostics are invalid')
normalized_diagnostics = []
for reference in diagnostics:
if not isinstance(reference, dict) or set(reference) != _DIAGNOSTIC_REFERENCE_FIELDS:
raise WorkerLocalStateError('terminal history diagnostic reference is invalid')
uid = reference.get('diagnostic_uid')
if not isinstance(uid, str) or _SHA256.fullmatch(uid) is None:
raise WorkerLocalStateError('terminal history diagnostic identity is invalid')
record_path = _relative_path(reference.get('record'), 'diagnostic record path')
artifacts = reference.get('artifacts')
if not isinstance(artifacts, dict) or set(artifacts) != {'body', 'stdout', 'stderr'}:
raise WorkerLocalStateError('terminal history diagnostic artifacts are invalid')
normalized_artifacts = {}
for name, artifact in artifacts.items():
if artifact is None:
normalized_artifacts[name] = None
continue
if not isinstance(artifact, dict) or set(artifact) != _ARTIFACT_FIELDS:
raise WorkerLocalStateError('terminal history diagnostic artifact is invalid')
if (
_SHA256.fullmatch(str(artifact.get('sha256') or '')) is None
or _SHA256.fullmatch(str(artifact.get('original_sha256') or '')) is None
or type(artifact.get('size')) is not int or artifact['size'] < 0
or type(artifact.get('original_size')) is not int or artifact['original_size'] < artifact['size']
or artifact.get('encoding') not in {'text', 'base64'}
or type(artifact.get('truncated')) is not bool
):
raise WorkerLocalStateError('terminal history diagnostic artifact fields are invalid')
normalized_artifacts[name] = {
**artifact,
'path': _relative_path(artifact.get('path'), 'diagnostic artifact path'),
}
normalized_diagnostics.append({
'diagnostic_uid': uid,
'record': record_path,
'artifacts': normalized_artifacts,
})
timeline = value.get('timeline')
if not isinstance(timeline, list) or len(timeline) > 1000:
raise WorkerLocalStateError('terminal history timeline is invalid')
normalized_timeline = []
previous_sequence = 0
for event in timeline:
if not isinstance(event, dict):
raise WorkerLocalStateError('terminal history timeline event is invalid')
event_fields = frozenset(event)
if legacy and event_fields == _LEGACY_TIMELINE_FIELDS:
legacy_event = True
elif event_fields == _TIMELINE_FIELDS:
legacy_event = False
else:
raise WorkerLocalStateError('terminal history timeline event is invalid')
sequence = event.get('sequence')
if type(sequence) is not int or sequence <= previous_sequence:
raise WorkerLocalStateError('terminal history timeline sequence is invalid')
previous_sequence = sequence
event_instance = value['instance_id'] if legacy_event else event.get('instance_id')
if not isinstance(event_instance, str) or not event_instance:
raise WorkerLocalStateError('terminal history timeline instance is invalid')
try:
phase = WorkerPhase(event.get('phase')).value
except (TypeError, ValueError) as exc:
raise WorkerLocalStateError('terminal history timeline phase is invalid') from exc
event_progress = {} if legacy_event else event.get('progress')
if not isinstance(event_progress, dict):
raise WorkerLocalStateError('terminal history timeline progress is invalid')
try:
_canonical_json(event_progress)
except (TypeError, ValueError) as exc:
raise WorkerLocalStateError('terminal history timeline progress is invalid') from exc
normalized_timeline.append({
'sequence': sequence,
'timestamp': _history_timestamp(event.get('timestamp'), 'timeline timestamp'),
'instance_id': event_instance,
'phase': phase,
'progress': dict(event_progress),
})
durations = value.get('phase_durations')
if not isinstance(durations, dict):
raise WorkerLocalStateError('terminal history phase durations are invalid')
normalized_durations = {}
for phase, seconds in durations.items():
try:
phase = WorkerPhase(phase).value
except (TypeError, ValueError) as exc:
raise WorkerLocalStateError('terminal history phase duration key is invalid') from exc
if type(seconds) not in (int, float) or not math.isfinite(seconds) or seconds < 0:
raise WorkerLocalStateError('terminal history phase duration is invalid')
normalized_durations[phase] = seconds
normalized = dict(value)
normalized['schema'] = HISTORY_SCHEMA
normalized['started_at'] = started_at
normalized['completed_at'] = completed_at
normalized['diagnostics'] = normalized_diagnostics
normalized['timeline'] = normalized_timeline
normalized['phase_durations'] = normalized_durations
return normalized
class WorkerLocalState:
"""Append-only worker event authority with rebuildable local views."""
def __init__(
self, state_root, *, log_bytes=DEFAULT_LOG_BYTES,
log_files=DEFAULT_LOG_FILES, retention_days=DEFAULT_RETENTION_DAYS,
retention_bytes=DEFAULT_RETENTION_BYTES, read_only=False,
event_segment_bytes=DEFAULT_EVENT_SEGMENT_BYTES,
history_segment_bytes=DEFAULT_HISTORY_SEGMENT_BYTES,
):
self.read_only = bool(read_only)
self.root = os.path.abspath(state_root)
directory = require_private_directory if self.read_only else (
lambda path, create=False: ensure_private_directory(path, reject_reparse=True)
)
self.root = directory(self.root)
self.control_dir = directory(os.path.join(self.root, 'control'))
self.events_dir = directory(os.path.join(self.root, 'events'))
self.history_dir = directory(os.path.join(self.root, 'history'))
self.diagnostics_dir = directory(os.path.join(self.root, 'diagnostics'))
self.logs_dir = directory(os.path.join(self.root, 'logs'))
self.event_path = os.path.join(self.events_dir, 'worker-events.jsonl')
self.history_path = os.path.join(self.history_dir, 'worker-history.jsonl')
self.status_path = os.path.join(self.control_dir, 'worker.status.json')
self.log_path = os.path.join(self.logs_dir, 'worker.log')
self.log_bytes = max(64 * 1024, int(log_bytes))
self.log_files = max(1, min(20, int(log_files)))
self.retention_days = max(1, int(retention_days))
self.retention_bytes = max(1024 * 1024, int(retention_bytes))
self.event_segment_bytes = max(1024, int(event_segment_bytes))
self.history_segment_bytes = max(1024, int(history_segment_bytes))
self._lock = threading.RLock()
self._sequence = 0
self._events = {}
self._event_segments = []
self._active_event_first = None
self._active_event_last = None
self._active_sparse_offsets = {}
self._event_end_offset = 0
self._history_segment_index = 0
self._active_history_records = 0
self._segment_readers = {}
self._history_keys = set()
self.recover()
@property
def next_sequence(self):
with self._lock:
return self._sequence + 1
def _require_writer(self):
if self.read_only:
raise WorkerLocalStateError('local worker state reader cannot mutate authority')
@staticmethod
def _truncate_partial_final_line(path, maximum, label):
if not os.path.exists(path):
return
reject_reparse_components(path)
if not private_file_ready(path):
raise WorkerLocalStateError(f'{label} is not private')
with open(path, 'r+b') as handle:
handle.seek(0, os.SEEK_END)
size = handle.tell()
if size == 0:
return
handle.seek(-1, os.SEEK_END)
if handle.read(1) == b'\n':
return
tail_size = min(size, maximum + 1)
handle.seek(size - tail_size)
tail = handle.read(tail_size)
newline = tail.rfind(b'\n')
if newline < 0 and size > maximum:
raise WorkerLocalStateError(f'truncated {label} line exceeds its bound')
boundary = size - tail_size + newline + 1
handle.seek(boundary)
handle.truncate()
handle.flush()
os.fsync(handle.fileno())
def _closed_event_paths(self):
values = []
for name in os.listdir(self.events_dir):
match = _EVENT_SEGMENT_RE.fullmatch(name)
if match:
values.append((int(match.group(1)), int(match.group(2)), os.path.join(self.events_dir, name)))
return sorted(values)
def _closed_history_paths(self):
values = []
for name in os.listdir(self.history_dir):
match = _HISTORY_SEGMENT_RE.fullmatch(name)
if match:
values.append((int(match.group(1)), os.path.join(self.history_dir, name)))
return sorted(values)
@staticmethod
def _read_event_line(handle):
raw = handle.readline(MAX_EVENT_LINE_BYTES + 1)
if not raw:
return None
if len(raw) > MAX_EVENT_LINE_BYTES:
raise WorkerLocalStateError('event journal line exceeds its bound')
if not raw.endswith(b'\n'):
return False
return raw
def recover(self):
with self._lock:
self._sequence = 0
self._events = {}
self._event_segments = []
self._active_event_first = None
self._active_event_last = None
self._active_sparse_offsets = {}
self._event_end_offset = 0
if not self.read_only:
self._truncate_partial_final_line(
self.event_path, MAX_EVENT_LINE_BYTES, 'event journal',
)
phases = {}
event_paths = self._closed_event_paths()
if os.path.exists(self.event_path):
event_paths.append((None, None, self.event_path))
for declared_first, declared_last, event_path in event_paths:
observed_first = None
observed_last = None
active = event_path == self.event_path
with open(event_path, 'rb') as handle:
while True:
offset = handle.tell()
raw = self._read_event_line(handle)
if raw is None:
break
if raw is False and active and self.read_only:
break
if raw is False:
raise WorkerLocalStateError('event journal line is invalid')
event = decode_worker_event(raw[:-1])
if self._sequence and event.sequence != self._sequence + 1:
raise WorkerLocalStateError('event journal sequence is not contiguous')
if not self._sequence and event.sequence < 1:
raise WorkerLocalStateError('event journal sequence is invalid')
key = (event.instance_id, event.slot_id)
previous = phases.get(key)
if previous is not None:
validate_phase_transition(previous, event.phase)
phases[key] = event.phase
self._events[event.slot_id] = event
observed_first = observed_first or event.sequence
observed_last = event.sequence
if active and (
observed_first == event.sequence
or (event.sequence - observed_first) % EVENT_CURSOR_STRIDE == 0
):
self._active_sparse_offsets[event.sequence] = offset
if active:
self._event_end_offset = handle.tell()
self._sequence = event.sequence
if active:
self._active_event_first = observed_first
self._active_event_last = observed_last
elif (
observed_first != declared_first
or observed_last != declared_last
or observed_first is None
):
raise WorkerLocalStateError('closed event segment identity is invalid')
else:
self._event_segments.append({
'first': observed_first,
'last': observed_last,
'path': event_path,
'bytes': os.path.getsize(event_path),
})
if not self.read_only:
self._truncate_partial_final_line(
self.history_path, MAX_HISTORY_LINE_BYTES, 'worker history',
)
self._load_history_keys()
if not self.read_only:
self._write_projection()
return self.snapshot()
def _load_history_keys(self):
self._history_keys = set()
closed = self._closed_history_paths()
self._history_segment_index = max((index for index, _path in closed), default=0)
paths = [(path, False) for _index, path in closed]
if os.path.exists(self.history_path):
paths.append((self.history_path, True))
self._active_history_records = 0
for path, active in paths:
reject_reparse_components(path)
if not private_file_ready(path):
raise WorkerLocalStateError('worker history is not private')
with open(path, 'rb') as handle:
while raw := handle.readline(MAX_HISTORY_LINE_BYTES + 1):
if len(raw) > MAX_HISTORY_LINE_BYTES:
raise WorkerLocalStateError('worker history line exceeds its bound')
if not raw.endswith(b'\n'):
if active and self.read_only:
break
raise WorkerLocalStateError('worker history line is invalid')
value = self._decode_history_line(raw)
key = value['history_id']
if key in self._history_keys:
raise WorkerLocalStateError('worker history identity is duplicated')
self._history_keys.add(key)
if active:
self._active_history_records += 1
@staticmethod
def _decode_history_line(raw):
try:
value = json.loads(raw.decode('ascii'))
if _canonical_json(value) != raw[:-1]:
raise WorkerLocalStateError('worker history is not canonical')
return validate_history_record(value)
except WorkerLocalStateError:
raise
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
raise WorkerLocalStateError('worker history is invalid') from exc
def _rotate_event_if_needed(self, incoming):
if (
self._active_event_first is None
or self._event_end_offset + incoming <= self.event_segment_bytes
):
return
destination = os.path.join(
self.events_dir,
f'worker-events.{self._active_event_first}-{self._active_event_last}.jsonl',
)
if os.path.exists(destination):
raise WorkerLocalStateError('event segment destination already exists')
durable_replace(self.event_path, destination)
self._event_segments.append({
'first': self._active_event_first,
'last': self._active_event_last,
'path': destination,
'bytes': self._event_end_offset,
})
self._active_event_first = None
self._active_event_last = None
self._active_sparse_offsets = {}
self._event_end_offset = 0
def _rotate_history_if_needed(self, incoming):
current = os.path.getsize(self.history_path) if os.path.exists(self.history_path) else 0
if self._active_history_records == 0 or current + incoming <= self.history_segment_bytes:
return
self._history_segment_index += 1
destination = os.path.join(
self.history_dir,
f'worker-history.{self._history_segment_index}.jsonl',
)
if os.path.exists(destination):
raise WorkerLocalStateError('history segment destination already exists')
durable_replace(self.history_path, destination)
self._active_history_records = 0
def _retain_segment_paths(self, paths):
for path in paths:
self._segment_readers[path] = self._segment_readers.get(path, 0) + 1
def _release_segment_paths(self, paths):
with self._lock:
for path in paths:
count = self._segment_readers.get(path, 0) - 1
if count > 0:
self._segment_readers[path] = count
else:
self._segment_readers.pop(path, None)
def _projection(self):
slots = []
for slot_id in sorted(self._events):
event = self._events[slot_id]
slots.append({
'slot_id': event.slot_id,
'sequence': event.sequence,
'timestamp': event.timestamp,
'instance_id': event.instance_id,
'reservation_id': event.reservation_id,
'source': event.source,
'phase': event.phase.value,
'phase_started_at': event.phase_started_at,
'scan_deadline_at': event.scan_deadline_at,
'assignment_deadline_at': event.assignment_deadline_at,
'progress': dict(event.progress),
})
counts = {}
for slot in slots:
counts[slot['phase']] = counts.get(slot['phase'], 0) + 1
return {
'schema': STATUS_SCHEMA,
'sequence': self._sequence,
'updated_at': max((item['timestamp'] for item in slots), default=None),
'aggregate': {'slot_count': len(slots), 'phases': counts},
'slots': slots,
}
def _write_projection(self):
atomic_write_private_json(self.status_path, self._projection())
def snapshot(self):
with self._lock:
return self._projection()
def emit_phase(
self, instance_id, slot_id, phase, *, reservation_id=None,
source=None, phase_started_at=None, scan_deadline_at=None,
assignment_deadline_at=None, progress=None, timestamp=None,
):
self._require_writer()
with self._lock:
phase = WorkerPhase(phase)
previous = self._events.get(int(slot_id))
if previous is not None and previous.instance_id == str(instance_id):
validate_phase_transition(previous.phase, phase)
if timestamp is None:
timestamp = utc_now()
if (
previous is not None
and datetime.fromisoformat(timestamp[:-1] + '+00:00')
< datetime.fromisoformat(previous.timestamp[:-1] + '+00:00')
):
timestamp = previous.timestamp
phase_started_at = (
previous.phase_started_at
if previous is not None
and previous.instance_id == str(instance_id)
and previous.phase == phase
else (phase_started_at or timestamp)
)
event = WorkerEvent(
schema=WORKER_EVENT_SCHEMA,
sequence=self._sequence + 1,
timestamp=timestamp,
instance_id=str(instance_id),
slot_id=int(slot_id),
reservation_id=(int(reservation_id) if reservation_id else None),
source=(str(source) if source else None),
type=WORKER_EVENT_TYPE,
phase=phase,
phase_started_at=phase_started_at,
scan_deadline_at=scan_deadline_at,
assignment_deadline_at=assignment_deadline_at,
progress=dict(progress or {}),
)
encoded = encode_worker_event(event)
self._rotate_event_if_needed(len(encoded) + 1)
offset = self._event_end_offset
_append_private(self.event_path, encoded + b'\n', MAX_EVENT_LINE_BYTES)
self._sequence = event.sequence
self._events[event.slot_id] = event
if self._active_event_first is None:
self._active_event_first = event.sequence
self._active_event_last = event.sequence
if (
event.sequence == self._active_event_first
or (event.sequence - self._active_event_first) % EVENT_CURSOR_STRIDE == 0
):
self._active_sparse_offsets[event.sequence] = offset
self._event_end_offset = offset + len(encoded) + 1
self._write_projection()
return _event_value(event)
def events_after(self, sequence=0, limit=256):
sequence = max(0, int(sequence))
limit = max(1, min(1000, int(limit)))
if not os.path.exists(self.event_path):
with self._lock:
if not self._event_segments:
return []
with self._lock:
if sequence >= self._sequence:
return []
segments = [dict(item) for item in self._event_segments]
if self._active_event_first is not None:
segments.append({
'first': self._active_event_first,
'last': self._active_event_last,
'path': self.event_path,
'bytes': self._event_end_offset,
'sparse': dict(self._active_sparse_offsets),
})
selected = [item for item in segments if item['last'] > sequence]
paths = [item['path'] for item in selected]
self._retain_segment_paths(paths)
results = []
try:
for segment in selected:
offset = 0
sparse = segment.get('sparse') or {}
if sparse:
candidates = [item for item in sparse if item <= sequence + 1]
if candidates:
offset = sparse[max(candidates)]
with open(segment['path'], 'rb') as handle:
handle.seek(offset)
while handle.tell() < segment['bytes']:
remaining = segment['bytes'] - handle.tell()
raw = handle.readline(min(MAX_EVENT_LINE_BYTES + 1, remaining + 1))
if len(raw) > MAX_EVENT_LINE_BYTES:
raise WorkerLocalStateError('event journal line exceeds its bound')
if not raw.endswith(b'\n'):
break
event = decode_worker_event(raw[:-1])
if event.sequence > sequence:
results.append(_event_value(event))
if len(results) >= limit:
return results
finally:
self._release_segment_paths(paths)
return results
def assignment_timeline(self, slot_id, reservation_id, limit=1000):
limit = max(1, min(1000, int(limit)))
selected = deque(maxlen=limit)
if not os.path.exists(self.event_path):
with self._lock:
if not self._event_segments:
return []
with self._lock:
segments = [dict(item) for item in self._event_segments]
if self._active_event_first is not None:
segments.append({
'first': self._active_event_first,
'last': self._active_event_last,
'path': self.event_path,
'bytes': self._event_end_offset,
})
paths = [item['path'] for item in segments]
self._retain_segment_paths(paths)
try:
for segment in segments:
with open(segment['path'], 'rb') as handle:
while handle.tell() < segment['bytes']:
remaining = segment['bytes'] - handle.tell()
raw = handle.readline(min(MAX_EVENT_LINE_BYTES + 1, remaining + 1))
if len(raw) > MAX_EVENT_LINE_BYTES:
raise WorkerLocalStateError('event journal line exceeds its bound')
if not raw.endswith(b'\n'):
break
event = decode_worker_event(raw[:-1])
if (
event.slot_id == int(slot_id)
and event.reservation_id == int(reservation_id)
):
selected.append({
'sequence': event.sequence,
'timestamp': event.timestamp,
'instance_id': event.instance_id,
'phase': event.phase.value,
'progress': dict(event.progress),
})
finally:
self._release_segment_paths(paths)
return list(selected)
def append_history(self, value):
self._require_writer()
record = validate_history_record({'schema': HISTORY_SCHEMA, **dict(value)})
history_id = record['history_id']
payload = _canonical_json(record) + b'\n'
with self._lock:
if history_id in self._history_keys:
return False
self._rotate_history_if_needed(len(payload))
_append_private(self.history_path, payload, MAX_HISTORY_LINE_BYTES)
self._history_keys.add(history_id)
self._active_history_records += 1
return True
def history(self, limit=100, reservation_id=None):
limit = max(1, min(1000, int(limit)))
selected = deque(maxlen=limit)
with self._lock:
paths = [path for _index, path in self._closed_history_paths()]
if os.path.exists(self.history_path):
paths.append(self.history_path)
self._retain_segment_paths(paths)
try:
for path in paths:
with open(path, 'rb') as handle:
while raw := handle.readline(MAX_HISTORY_LINE_BYTES + 1):
if len(raw) > MAX_HISTORY_LINE_BYTES:
raise WorkerLocalStateError('worker history line exceeds its bound')
if not raw.endswith(b'\n'):
break
value = self._decode_history_line(raw)
if reservation_id is None or int(value.get('reservation_id') or 0) == int(reservation_id):
rendered = dict(value)
rendered['diagnostics'] = []
for diagnostic in value.get('diagnostics') or []:
item = dict(diagnostic)
record = item.get('record')
item['available'] = bool(
record and os.path.isfile(os.path.join(self.root, *str(record).split('/')))
)
artifacts = dict(item.get('artifacts') or {})
item['artifact_availability'] = {
name: bool(
details and details.get('path') and os.path.isfile(
os.path.join(self.root, *str(details['path']).split('/'))
)
)
for name, details in artifacts.items()
}
rendered['diagnostics'].append(item)
selected.append(rendered)
finally:
self._release_segment_paths(paths)
return list(selected)
def _rotate_log(self, incoming):
current = os.path.getsize(self.log_path) if os.path.exists(self.log_path) else 0
if current + incoming <= self.log_bytes:
return
oldest = f'{self.log_path}.{self.log_files}'
if os.path.exists(oldest):
durable_unlink(oldest)
for index in range(self.log_files - 1, 0, -1):
source = self.log_path if index == 1 else f'{self.log_path}.{index - 1}'
destination = f'{self.log_path}.{index}'
if os.path.exists(source):
os.replace(source, destination)
fsync_directory(self.logs_dir)
def log(self, message, *, timestamp=None):
self._require_writer()
text = str(message).replace('\r', '\\r').replace('\n', '\\n')
payload = f'{timestamp or utc_now()} {text}\n'.encode('utf-8', errors='replace')
if len(payload) > MAX_LOG_LINE_BYTES:
payload = payload[:MAX_LOG_LINE_BYTES - 1] + b'\n'
with self._lock:
self._rotate_log(len(payload))
_append_private(self.log_path, payload, MAX_LOG_LINE_BYTES)
def log_tail(self, limit=100):
limit = max(1, min(10000, int(limit)))
lines = deque(maxlen=limit)
paths = [f'{self.log_path}.{index}' for index in range(self.log_files, 0, -1)] + [self.log_path]
with self._lock:
for path in paths:
if not os.path.exists(path):
continue
with open(path, 'rb') as handle:
while raw := handle.readline(MAX_LOG_LINE_BYTES + 1):
if len(raw) > MAX_LOG_LINE_BYTES:
raise WorkerLocalStateError('worker log line exceeds its bound')
lines.append(raw.decode('utf-8', errors='replace').rstrip('\r\n'))
return list(lines)
def _artifact(self, directory, name, material, full_payload=None):
if material is None and full_payload is None:
return None
payload = (
bytes(full_payload)
if full_payload is not None
else diagnostic_material_bytes(material)
)
path = os.path.join(directory, name)
_write_private_bytes(path, payload)
relative = os.path.relpath(path, self.root).replace(os.sep, '/')
digest = hashlib.sha256(payload).hexdigest()
if full_payload is not None:
try:
payload.decode('utf-8', errors='strict')
encoding = 'text'
except UnicodeDecodeError:
encoding = 'base64'
else:
encoding = material.encoding.value
return {
'path': relative,
'sha256': digest,
'original_sha256': digest if full_payload is not None else material.sha256,
'size': len(payload),
'original_size': len(payload) if full_payload is not None else material.original_size,
'encoding': encoding,
'truncated': False if full_payload is not None else material.truncated,
}
def archive_diagnostic(self, envelope, full_materials=None):
self._require_writer()
full_materials = dict(full_materials or {})
if set(full_materials) - {'body', 'stdout', 'stderr'}:
raise WorkerLocalStateError('diagnostic full material shape is invalid')
encoded = encode_diagnostic_envelope(envelope)
envelope = decode_diagnostic_envelope(encoded)
date = envelope.captured_at[:10]
directory = ensure_private_directory(
os.path.join(self.diagnostics_dir, date, str(envelope.reservation_id)),
reject_reparse=True,
)
uid = envelope.diagnostic_uid
artifacts = {
'body': self._artifact(
directory, f'{uid}.body', envelope.http.body if envelope.http else None,
full_materials.get('body'),
),
'stdout': self._artifact(
directory, f'{uid}.stdout.log', envelope.process.stdout if envelope.process else None,
full_materials.get('stdout'),
),
'stderr': self._artifact(
directory, f'{uid}.stderr.log', envelope.process.stderr if envelope.process else None,
full_materials.get('stderr'),
),
}
record = {
'schema': DIAGNOSTIC_ARCHIVE_SCHEMA,
'envelope': json.loads(encoded.decode('ascii')),
'artifacts': artifacts,
}
path = os.path.join(directory, f'{uid}.json')
atomic_write_private_json(path, record, max_bytes=2 * 1024 * 1024)
return {
'diagnostic_uid': uid,
'record': os.path.relpath(path, self.root).replace(os.sep, '/'),
'artifacts': artifacts,
}
def diagnostic_references(self, reservation_id):
reservation_name = str(int(reservation_id))
references = []
for date in sorted(os.listdir(self.diagnostics_dir)):
directory = os.path.join(self.diagnostics_dir, date, reservation_name)
if not os.path.isdir(directory) or os.path.islink(directory):
continue
for name in sorted(os.listdir(directory)):
if not name.endswith('.json'):
continue
path = os.path.join(directory, name)
try:
value = read_private_json(path, max_bytes=2 * 1024 * 1024)
except OSError:
continue
if not isinstance(value, dict) or set(value) != {
'schema', 'envelope', 'artifacts',
} or value.get('schema') != DIAGNOSTIC_ARCHIVE_SCHEMA:
raise WorkerLocalStateError('diagnostic archive record is invalid')
envelope = value.get('envelope')
artifacts = value.get('artifacts')
if not isinstance(envelope, dict) or not isinstance(artifacts, dict):
raise WorkerLocalStateError('diagnostic archive record is invalid')
try:
decoded = decode_diagnostic_envelope(_canonical_json(envelope))
except ValueError as exc:
raise WorkerLocalStateError('diagnostic archive envelope is invalid') from exc
if decoded.reservation_id != int(reservation_id):
raise WorkerLocalStateError('diagnostic archive reservation is invalid')
reference = {
'diagnostic_uid': decoded.diagnostic_uid,
'record': os.path.relpath(path, self.root).replace(os.sep, '/'),
'artifacts': artifacts,
}
validate_history_record({
'schema': HISTORY_SCHEMA,
'history_id': 'diagnostic-reference-validation',
'instance_id': 'diagnostic-reference-validation',
'slot_id': 0,
'reservation_id': int(reservation_id),
'source': None,
'outcome': 'validation',
'receipt': {},
'started_at': None,
'completed_at': utc_now(),
'duration_seconds': None,
'first_sequence': None,
'last_sequence': None,
'diagnostics': [reference],
'timeline': [],
'phase_durations': {},
})
references.append(reference)
return references
def _active_reservations(self):
reservations = set()
reliable = True
with os.scandir(self.root) as entries:
for entry in entries:
if not re.fullmatch(r'slot-(0|[1-9][0-9]*)\.json', entry.name):
continue
try:
state = read_private_json(entry.path, max_bytes=64 * 1024 * 1024)
reservation = dict((state.get('assignment') or {}).get('reservation') or {})
reservation_id = int(reservation.get('reservation_id') or 0)
if reservation_id > 0:
reservations.add(reservation_id)
except (OSError, TypeError, ValueError):
reliable = False
return reservations, reliable
def _diagnostic_is_evictable(self, path, active_reservations, reliable):
if not reliable:
return False
relative = os.path.relpath(path, self.diagnostics_dir).split(os.sep)
if len(relative) < 3:
return True
try:
reservation_id = int(relative[1])
except ValueError:
return False
return reservation_id not in active_reservations
def _retained_history_authority(self):
retained_paths = set()
retained_event_sequences = set()
paths = [path for _index, path in self._closed_history_paths()]
if os.path.exists(self.history_path):
paths.append(self.history_path)
for path in paths:
with open(path, 'rb') as handle:
while raw := handle.readline(MAX_HISTORY_LINE_BYTES + 1):
if len(raw) > MAX_HISTORY_LINE_BYTES or not raw.endswith(b'\n'):
break
value = self._decode_history_line(raw)
retained_event_sequences.update(
event['sequence'] for event in value['timeline']
)
for reference in value['diagnostics']:
retained_paths.add(os.path.abspath(os.path.join(
self.root, *reference['record'].split('/'),
)))
for artifact in reference['artifacts'].values():
if artifact is not None:
retained_paths.add(os.path.abspath(os.path.join(
self.root, *artifact['path'].split('/'),
)))
return retained_paths, retained_event_sequences
def _event_segment_is_evictable(
self, path, active_reservations, active_reliable,
retained_event_sequences,
):
if not active_reliable:
return False
with open(path, 'rb') as handle:
while raw := handle.readline(MAX_EVENT_LINE_BYTES + 1):
if len(raw) > MAX_EVENT_LINE_BYTES or not raw.endswith(b'\n'):
return False
event = decode_worker_event(raw[:-1])
if event.reservation_id in active_reservations:
return False
if (
event.reservation_id is not None
and event.sequence not in retained_event_sequences
):
return False
return True
def _evictable_event_prefix(
self, active_reservations, active_reliable,
retained_event_sequences, progress_cursor,
):
paths = set()
for _first, last, path in self._closed_event_paths():
if progress_cursor is not None and last > progress_cursor:
break
if not self._event_segment_is_evictable(
path, active_reservations, active_reliable,
retained_event_sequences,
):
break
paths.add(os.path.abspath(path))
return paths
def _progress_outbox_cursor(self):
old_path = os.path.join(self.root, 'progress-outbox.json')
path = os.path.join(
self.root, *PROGRESS_OUTBOX_RELATIVE_PATH.split('/'),
)
try:
old_sequence = (
_progress_cursor_value(old_path) if os.path.exists(old_path) else None
)
new_sequence = (
_progress_cursor_value(path) if os.path.exists(path) else None
)
except (OSError, ValueError, WorkerLocalStateError):
return 0
if old_sequence is not None and new_sequence is not None:
return old_sequence if old_sequence == new_sequence else 0
sequence = new_sequence if new_sequence is not None else old_sequence
if sequence is not None:
return sequence
if os.path.exists(self.event_path) or self._closed_event_paths():
return 0
return None
def _effective_event_prefix(
self, structural_prefix, total_bytes, cutoff,
):
remaining_total = int(total_bytes)
effective = set()
with self._lock:
readers = dict(self._segment_readers)
for _first, _last, path in self._closed_event_paths():
absolute = os.path.abspath(path)
if absolute not in structural_prefix or readers.get(path, 0):
break
try:
details = os.stat(path, follow_symlinks=False)
except OSError:
break
if details.st_mtime >= cutoff and remaining_total <= self.retention_bytes:
break
effective.add(absolute)
remaining_total -= details.st_size
return effective
def _retention_snapshot(self, extra_roots=None, now=None):
now = time.time() if now is None else float(now)
cutoff = now - self.retention_days * 86400
roots = {
'control': self.control_dir,
'events': self.events_dir,
'history': self.history_dir,
'diagnostics': self.diagnostics_dir,
'logs': self.logs_dir,
}
roots.update(dict(extra_roots or {}))
categories = {}
total_bytes = 0
total_files = 0
total_evictable_bytes = 0
total_evictable_files = 0
active_reservations, active_reliable = self._active_reservations()
retained_diagnostics, retained_event_sequences = self._retained_history_authority()
progress_cursor = self._progress_outbox_cursor()
structural_event_prefix = self._evictable_event_prefix(
active_reservations, active_reliable, retained_event_sequences,
progress_cursor,
)
for name, root in roots.items():
byte_count = 0
file_count = 0
evictable_bytes = 0
evictable_files = 0
if os.path.isdir(root):
for current, directories, files in os.walk(root, followlinks=False):
directories[:] = [item for item in directories if not os.path.islink(os.path.join(current, item))]
for filename in files:
path = os.path.join(current, filename)
try:
details = os.stat(path, follow_symlinks=False)
except OSError:
continue
if stat.S_ISREG(details.st_mode):
byte_count += details.st_size
file_count += 1
evictable = (
(
self._diagnostic_is_evictable(
path, active_reservations, active_reliable,
)
and os.path.abspath(path) not in retained_diagnostics
) if name == 'diagnostics'
else (
(name == 'logs' and path != self.log_path)
)
)
if evictable:
evictable_bytes += details.st_size
evictable_files += 1
categories[name] = {
'bytes': byte_count,
'files': file_count,
'evictable_bytes': evictable_bytes,
'evictable_files': evictable_files,
'non_evictable_bytes': byte_count - evictable_bytes,
'non_evictable_files': file_count - evictable_files,
}
total_bytes += byte_count
total_files += file_count
total_evictable_bytes += evictable_bytes
total_evictable_files += evictable_files
state_bytes = 0
state_files = 0
with os.scandir(self.root) as entries:
for entry in entries:
try:
details = entry.stat(follow_symlinks=False)
except OSError:
continue
if stat.S_ISREG(details.st_mode):
state_bytes += details.st_size
state_files += 1
categories['state'] = {
'bytes': state_bytes,
'files': state_files,
'evictable_bytes': 0,
'evictable_files': 0,
'non_evictable_bytes': state_bytes,
'non_evictable_files': state_files,
}
total_bytes += state_bytes
total_files += state_files
effective_event_prefix = self._effective_event_prefix(
structural_event_prefix, total_bytes, cutoff,
)
event_evictable_bytes = 0
event_evictable_files = 0
for path in effective_event_prefix:
try:
event_evictable_bytes += os.stat(path, follow_symlinks=False).st_size
event_evictable_files += 1
except OSError:
continue
events = categories['events']
events['evictable_bytes'] = event_evictable_bytes
events['evictable_files'] = event_evictable_files
events['non_evictable_bytes'] = events['bytes'] - event_evictable_bytes
events['non_evictable_files'] = events['files'] - event_evictable_files
total_evictable_bytes += event_evictable_bytes
total_evictable_files += event_evictable_files
document = {
'schema': RETENTION_SCHEMA,
'maximum_age_days': self.retention_days,
'maximum_bytes': self.retention_bytes,
'total_bytes': total_bytes,
'total_files': total_files,
'evictable_bytes': total_evictable_bytes,
'evictable_files': total_evictable_files,
'non_evictable_bytes': total_bytes - total_evictable_bytes,
'non_evictable_files': total_files - total_evictable_files,
'over_limit': total_bytes > self.retention_bytes,
'progress_outbox': {
'cursor_sequence': progress_cursor,
'protected_after_sequence': (
progress_cursor if progress_cursor is not None else None
),
},
'categories': categories,
}
return document, effective_event_prefix
def retention_usage(self, extra_roots=None):
document, _event_prefix = self._retention_snapshot(extra_roots)
return document
def cleanup_retention(self, extra_roots=None):
self._require_writer()
now = time.time()
cutoff = now - self.retention_days * 86400
candidates = []
usage, evictable_event_prefix = self._retention_snapshot(
extra_roots, now=now,
)
active_reservations, active_reliable = self._active_reservations()
retained_diagnostics, retained_event_sequences = self._retained_history_authority()
for root in (
self.diagnostics_dir, self.logs_dir, self.events_dir,
):
for current, directories, files in os.walk(root, topdown=True, followlinks=False):
directories[:] = [item for item in directories if not os.path.islink(os.path.join(current, item))]
for filename in files:
path = os.path.join(current, filename)
if path in {self.log_path, self.event_path} or os.path.islink(path):
continue
if root == self.events_dir and _EVENT_SEGMENT_RE.fullmatch(filename) is None:
continue
if root == self.diagnostics_dir and not self._diagnostic_is_evictable(
path, active_reservations, active_reliable,
):
continue
if root == self.diagnostics_dir and os.path.abspath(path) in retained_diagnostics:
continue
if (
root == self.events_dir
and os.path.abspath(path) not in evictable_event_prefix
):
continue
try:
details = os.stat(path, follow_symlinks=False)
except OSError:
continue
if stat.S_ISREG(details.st_mode):
event_match = _EVENT_SEGMENT_RE.fullmatch(filename)
if root == self.events_dir and event_match:
kind = 'events'
order = int(event_match.group(1))
sort_key = (0, order)
else:
kind = None
order = 0
sort_key = (1, details.st_mtime)
candidates.append((
sort_key, details.st_mtime, details.st_size,
path, kind, order,
))
removed_bytes = 0
removed_files = 0
total_bytes = usage['total_bytes']
blocked_segments = set()
with self._lock:
for _sort_key, modified, size, path, kind, _order in sorted(candidates):
if kind in blocked_segments:
continue
if modified >= cutoff and total_bytes <= self.retention_bytes:
if kind is not None:
blocked_segments.add(kind)
continue
if self._segment_readers.get(path, 0):
if kind is not None:
blocked_segments.add(kind)
continue
try:
durable_unlink(path)
except FileNotFoundError:
continue
removed_bytes += size
removed_files += 1
total_bytes -= size
if root_path := next((
segment for segment in self._event_segments
if segment['path'] == path
), None):
self._event_segments.remove(root_path)
return {'removed_bytes': removed_bytes, 'removed_files': removed_files}