Files
Elydora-Open-Source/sdks/python/elydora/plugins/_transaction.py
T
雷电芽衣 bc6983a62e chore(oss): audit the server package and resync the SDK mirrors
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
2026-09-03 20:48:20 -04:00

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,
)