Split the 657-line operation service into eor, submit, and verify modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already emits, and condense every multi-line comment. Mirror Node, Python, and Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation grace, and path-encoding fixes. Document which routes and response fields the self-hosted server does not provide. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
468 lines
14 KiB
Python
468 lines
14 KiB
Python
"""Fail-fast transactional file changes shared by hook adapters."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import List, Optional, Sequence, cast
|
|
from uuid import uuid4
|
|
|
|
from ._managed_files import FileSnapshot, MAX_SOURCE_BYTES, read_physical_file
|
|
from ._opaque_cleanup import cleanup_opaque_removal
|
|
from ._opaque_removal import (
|
|
OpaqueRemovalChange,
|
|
StagedOpaqueRemoval,
|
|
assert_staged_opaque,
|
|
commit_opaque_removal,
|
|
prepare_opaque_rollback,
|
|
rollback_opaque_removal,
|
|
stage_opaque_removal,
|
|
)
|
|
from ._pinned_directory import PinnedDirectory
|
|
from ._transaction_commit import commit_change, prepare_change, rollback_change
|
|
from ._transaction_journal import (
|
|
FileFingerprint,
|
|
JournalRecord,
|
|
TransactionJournal,
|
|
active_journal_path,
|
|
clear_journal,
|
|
write_journal,
|
|
)
|
|
from ._transaction_lock import serialized_transactions
|
|
from ._transaction_restart import (
|
|
recover_pending_locked,
|
|
recover_pending_transactions as _recover_pending_transactions,
|
|
)
|
|
from ._transaction_staging import (
|
|
assert_transaction_preconditions,
|
|
cleanup_created_directories,
|
|
cleanup_staged,
|
|
close_directories,
|
|
directory_for,
|
|
pin_directories,
|
|
reserve_change,
|
|
same_snapshot,
|
|
stage_change,
|
|
)
|
|
from ._transaction_types import (
|
|
DirectoryMap,
|
|
DirectoryPrecondition,
|
|
FileChange,
|
|
FilePrecondition,
|
|
StagedChange,
|
|
)
|
|
|
|
|
|
_EXPECTED_SNAPSHOT_UNSET = object()
|
|
|
|
|
|
def recover_pending_transactions() -> None:
|
|
"""Recover a journal left by a terminated transaction process."""
|
|
_recover_pending_transactions()
|
|
|
|
|
|
def source_change(
|
|
file_path: str,
|
|
label: str,
|
|
original: Optional[str],
|
|
next_source: Optional[str],
|
|
mode: int,
|
|
maximum_bytes: int = MAX_SOURCE_BYTES,
|
|
expected_snapshot: object = _EXPECTED_SNAPSHOT_UNSET,
|
|
enforce_mode: bool = False,
|
|
) -> Optional[FileChange]:
|
|
if next_source is not None and len(next_source.encode("utf-8")) > maximum_bytes:
|
|
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
|
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
|
current = None if snapshot is None else snapshot.contents
|
|
if current != original:
|
|
raise OSError(f"{label} changed before staging: {file_path}")
|
|
if expected_snapshot is not _EXPECTED_SNAPSHOT_UNSET:
|
|
expected = cast(Optional[FileSnapshot], expected_snapshot)
|
|
if not same_snapshot(snapshot, expected):
|
|
raise OSError(f"{label} changed before staging: {file_path}")
|
|
if original == next_source and (
|
|
not enforce_mode or snapshot is None or snapshot.mode == mode
|
|
):
|
|
return None
|
|
return FileChange(
|
|
file_path,
|
|
label,
|
|
original,
|
|
next_source,
|
|
mode,
|
|
None if snapshot is None else snapshot.mode,
|
|
None if snapshot is None else snapshot.device,
|
|
None if snapshot is None else snapshot.inode,
|
|
maximum_bytes,
|
|
)
|
|
|
|
|
|
def file_change(
|
|
file_path: str,
|
|
label: str,
|
|
next_source: Optional[str],
|
|
mode: int,
|
|
maximum_bytes: int = MAX_SOURCE_BYTES,
|
|
enforce_mode: bool = False,
|
|
) -> Optional[FileChange]:
|
|
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
|
return source_change(
|
|
file_path,
|
|
label,
|
|
None if snapshot is None else snapshot.contents,
|
|
next_source,
|
|
mode,
|
|
maximum_bytes,
|
|
enforce_mode=enforce_mode,
|
|
)
|
|
|
|
|
|
def _replace_physical(
|
|
source_path: str,
|
|
destination_path: str,
|
|
directory: PinnedDirectory,
|
|
) -> None:
|
|
del source_path, destination_path, directory
|
|
|
|
|
|
def _restore_physical(
|
|
source_path: str,
|
|
destination_path: str,
|
|
directory: PinnedDirectory,
|
|
) -> None:
|
|
del source_path, destination_path, directory
|
|
|
|
|
|
def _capture_physical(
|
|
source_path: str,
|
|
destination_path: str,
|
|
directory: PinnedDirectory,
|
|
) -> None:
|
|
del source_path, destination_path, directory
|
|
|
|
|
|
def _after_replace_physical(
|
|
source_path: str,
|
|
destination_path: str,
|
|
directory: PinnedDirectory,
|
|
) -> None:
|
|
del source_path, destination_path, directory
|
|
|
|
|
|
def _after_journal_commit() -> None:
|
|
"""Fault-injection boundary after the committed marker is durable."""
|
|
|
|
|
|
def _commit(staged: StagedChange) -> None:
|
|
commit_change(
|
|
staged,
|
|
_replace_physical,
|
|
_capture_physical,
|
|
_after_replace_physical,
|
|
)
|
|
|
|
|
|
def _rollback(staged: StagedChange) -> None:
|
|
rollback_change(staged, _restore_physical, _capture_physical)
|
|
|
|
|
|
def _recover(
|
|
staged: Sequence[StagedChange],
|
|
opaque_staged: Sequence[StagedOpaqueRemoval],
|
|
directories: DirectoryMap,
|
|
directory_preconditions: Sequence[DirectoryPrecondition],
|
|
) -> List[str]:
|
|
errors = []
|
|
for staged_item in reversed(staged):
|
|
try:
|
|
_rollback(staged_item)
|
|
except Exception as error:
|
|
errors.append(str(error))
|
|
for opaque_item in reversed(opaque_staged):
|
|
try:
|
|
rollback_opaque_removal(opaque_item)
|
|
except Exception as error:
|
|
errors.append(str(error))
|
|
for staged_item in staged:
|
|
try:
|
|
cleanup_staged(staged_item)
|
|
except OSError as error:
|
|
errors.append(str(error))
|
|
for opaque_item in opaque_staged:
|
|
try:
|
|
cleanup_opaque_removal(opaque_item)
|
|
except OSError as error:
|
|
errors.append(str(error))
|
|
errors.extend(
|
|
cleanup_created_directories(directory_preconditions, directories)
|
|
)
|
|
errors.extend(close_directories(directories))
|
|
return errors
|
|
|
|
|
|
def _finish_committed(
|
|
staged: Sequence[StagedChange],
|
|
opaque_staged: Sequence[StagedOpaqueRemoval],
|
|
directories: DirectoryMap,
|
|
) -> List[str]:
|
|
errors = []
|
|
for staged_item in staged:
|
|
try:
|
|
cleanup_staged(staged_item)
|
|
except OSError as error:
|
|
errors.append(str(error))
|
|
for opaque_item in opaque_staged:
|
|
try:
|
|
cleanup_opaque_removal(opaque_item)
|
|
except OSError as error:
|
|
errors.append(str(error))
|
|
errors.extend(close_directories(directories))
|
|
if errors:
|
|
return errors
|
|
try:
|
|
clear_journal()
|
|
except OSError as error:
|
|
errors.append(str(error))
|
|
return errors
|
|
|
|
|
|
def _text_journal_record(staged: StagedChange) -> JournalRecord:
|
|
original = None
|
|
if staged.change.original is not None:
|
|
if (
|
|
staged.change.original_device is None
|
|
or staged.change.original_inode is None
|
|
or staged.change.original_mode is None
|
|
):
|
|
raise OSError(f"Missing original identity for {staged.change.label}")
|
|
original = FileFingerprint.from_text(
|
|
FileSnapshot(
|
|
staged.change.original,
|
|
staged.change.original_device,
|
|
staged.change.original_inode,
|
|
staged.change.original_mode,
|
|
)
|
|
)
|
|
next_value = (
|
|
None
|
|
if staged.temporary_snapshot is None
|
|
else FileFingerprint.from_text(staged.temporary_snapshot)
|
|
)
|
|
temporary_identity = (
|
|
None
|
|
if staged.temporary_reservation is None
|
|
else FileFingerprint.from_text(staged.temporary_reservation)
|
|
)
|
|
rollback = (
|
|
None
|
|
if staged.rollback_reservation is None
|
|
else FileFingerprint.from_text(staged.rollback_reservation)
|
|
)
|
|
return JournalRecord(
|
|
"text",
|
|
staged.change.label,
|
|
os.path.abspath(staged.change.file_path),
|
|
None if staged.temporary_path is None else os.path.abspath(staged.temporary_path),
|
|
None if staged.rollback_path is None else os.path.abspath(staged.rollback_path),
|
|
staged.directory.path,
|
|
staged.directory.snapshot,
|
|
original,
|
|
next_value,
|
|
temporary_identity,
|
|
rollback,
|
|
)
|
|
|
|
|
|
def _opaque_journal_record(staged: StagedOpaqueRemoval) -> JournalRecord:
|
|
original = (
|
|
None
|
|
if staged.change.original is None
|
|
else FileFingerprint.from_opaque(staged.change.original)
|
|
)
|
|
rollback = (
|
|
None
|
|
if staged.rollback_reservation is None
|
|
else FileFingerprint.from_text(staged.rollback_reservation)
|
|
)
|
|
return JournalRecord(
|
|
"opaque",
|
|
staged.change.label,
|
|
os.path.abspath(staged.change.file_path),
|
|
None,
|
|
None if staged.rollback_path is None else os.path.abspath(staged.rollback_path),
|
|
staged.directory.path,
|
|
staged.directory.snapshot,
|
|
original,
|
|
None,
|
|
None,
|
|
rollback,
|
|
)
|
|
|
|
|
|
def _write_changes_locked(
|
|
changes: Sequence[FileChange],
|
|
label: str,
|
|
preconditions: Sequence[FilePrecondition] = (),
|
|
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
|
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
|
) -> None:
|
|
filtered = [
|
|
change
|
|
for change in changes
|
|
if change.original != change.next_source
|
|
or change.original_mode != change.mode
|
|
]
|
|
directories: DirectoryMap = {}
|
|
staged: List[StagedChange] = []
|
|
opaque_staged: List[StagedOpaqueRemoval] = []
|
|
journal: Optional[TransactionJournal] = None
|
|
committed_marker_durable = False
|
|
try:
|
|
assert_transaction_preconditions(preconditions, directory_preconditions, label)
|
|
if not filtered and not opaque_removals:
|
|
return
|
|
paths = [
|
|
os.path.normcase(os.path.abspath(change.file_path)) for change in filtered
|
|
]
|
|
paths.extend(
|
|
os.path.normcase(os.path.abspath(change.file_path))
|
|
for change in opaque_removals
|
|
)
|
|
if len(paths) != len(set(paths)):
|
|
raise ValueError(f"{label} contains duplicate file targets")
|
|
directories = pin_directories(
|
|
filtered,
|
|
preconditions,
|
|
directory_preconditions,
|
|
[
|
|
(
|
|
os.path.dirname(change.file_path),
|
|
f"{change.label} directory",
|
|
True,
|
|
)
|
|
for change in opaque_removals
|
|
],
|
|
)
|
|
for opaque_change in opaque_removals:
|
|
opaque_staged.append(
|
|
stage_opaque_removal(
|
|
opaque_change,
|
|
directory_for(opaque_change.file_path, directories),
|
|
label,
|
|
)
|
|
)
|
|
for file_change_item in filtered:
|
|
staged.append(
|
|
reserve_change(
|
|
file_change_item,
|
|
preconditions,
|
|
directory_preconditions,
|
|
label,
|
|
directories,
|
|
)
|
|
)
|
|
records = [
|
|
*(_opaque_journal_record(item) for item in opaque_staged),
|
|
*(_text_journal_record(item) for item in staged),
|
|
]
|
|
journal = TransactionJournal(uuid4().hex, label, "preparing", records)
|
|
write_journal(journal)
|
|
for staged_item in staged:
|
|
stage_change(staged_item)
|
|
records = [
|
|
*(_opaque_journal_record(item) for item in opaque_staged),
|
|
*(_text_journal_record(item) for item in staged),
|
|
]
|
|
journal = TransactionJournal(
|
|
journal.transaction_id,
|
|
journal.label,
|
|
journal.state,
|
|
records,
|
|
)
|
|
write_journal(journal)
|
|
assert_transaction_preconditions(
|
|
preconditions, directory_preconditions, label, directories
|
|
)
|
|
for opaque_item in opaque_staged:
|
|
assert_staged_opaque(opaque_item, label)
|
|
for opaque_item in opaque_staged:
|
|
prepare_opaque_rollback(opaque_item)
|
|
for staged_item in staged:
|
|
prepare_change(staged_item, _capture_physical)
|
|
journal = journal.with_state("committing")
|
|
write_journal(journal)
|
|
for opaque_item in opaque_staged:
|
|
commit_opaque_removal(opaque_item)
|
|
for staged_item in staged:
|
|
_commit(staged_item)
|
|
assert_transaction_preconditions(
|
|
preconditions, directory_preconditions, label, directories
|
|
)
|
|
for opaque_item in opaque_staged:
|
|
assert_staged_opaque(opaque_item, label)
|
|
committed_journal = journal.with_state("committed")
|
|
write_journal(committed_journal)
|
|
journal = committed_journal
|
|
committed_marker_durable = True
|
|
_after_journal_commit()
|
|
except Exception as error:
|
|
if committed_marker_durable:
|
|
cleanup_errors = _finish_committed(
|
|
staged,
|
|
opaque_staged,
|
|
directories,
|
|
)
|
|
suffix = (
|
|
f"; cleanup pending in {active_journal_path()}: "
|
|
f"{'; '.join(cleanup_errors)}"
|
|
if cleanup_errors
|
|
else ""
|
|
)
|
|
raise OSError(
|
|
f"{label} committed before post-commit failure: {error}{suffix}"
|
|
) from error
|
|
recovery_errors = _recover(
|
|
staged,
|
|
opaque_staged,
|
|
directories,
|
|
directory_preconditions,
|
|
)
|
|
suffix = (
|
|
f"; recovery failed: {'; '.join(recovery_errors)}"
|
|
if recovery_errors
|
|
else ""
|
|
)
|
|
if journal is not None and not recovery_errors:
|
|
try:
|
|
clear_journal()
|
|
except OSError as journal_error:
|
|
suffix += f"; journal cleanup failed: {journal_error}"
|
|
elif journal is not None:
|
|
suffix += f"; recovery journal preserved at {active_journal_path()}"
|
|
raise OSError(f"{label}: {error}{suffix}") from error
|
|
|
|
cleanup_errors = _finish_committed(staged, opaque_staged, directories)
|
|
if cleanup_errors:
|
|
raise OSError(
|
|
f"{label} committed; cleanup pending in {active_journal_path()}: "
|
|
f"{'; '.join(cleanup_errors)}"
|
|
)
|
|
|
|
|
|
def write_changes(
|
|
changes: Sequence[FileChange],
|
|
label: str,
|
|
preconditions: Sequence[FilePrecondition] = (),
|
|
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
|
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
|
) -> None:
|
|
with serialized_transactions():
|
|
recover_pending_locked()
|
|
_write_changes_locked(
|
|
changes,
|
|
label,
|
|
preconditions,
|
|
directory_preconditions,
|
|
opaque_removals,
|
|
)
|