feat(python): durable transaction subsystem and Kiro IDE 1.0 adapter
Land the crash-recoverable managed-file transaction engine (journal, staging, commit, restart recovery, opaque snapshots, private artifacts, Windows DACL hardening) and the Kiro IDE 1.0 workspace hook plugin with legacy migration. Repair five defects that kept the suite red (426 failures to zero): - Anchor the transaction state directory at ~/.elydora/transactions instead of the shared system temp directory. The recovery journal now shares fate with the runtimes it repairs, survives temp cleaners, and isolates naturally under per-test fake homes. - Skip pending-transaction recovery when no state directory exists so rejected installs never create runtime directories. - Create the runtime root's parent before hardening it, matching the Go SDK's expand behavior for fresh homes. - Stop using file timestamps as identity evidence: Windows exposes mtime/ctime with delayed cross-handle visibility. Reads now prove stability through a verification re-read; snapshots compare device, inode, size, and SHA-256. - Stop comparing synthetic Windows permission bits across stat boundaries: path stats derive execute bits from file extensions while handle stats do not, which falsely flagged every managed .cmd wrapper. Snapshots store raw modes for restoration and compare portable bits. Reservation cleanup now verifies content before discarding so a concurrent writer's file at a rollback path is preserved, and the install-failure tests assert the precise contract: no agent runtime residue, with the durable state directory allowed to remain. Gates: pytest 807 passed, mypy clean, pip check clean, wheel builds.
This commit is contained in:
1 parent
dce7d1288a
commit
e37418c489
53 files changed
+11855
-531
No files matched your search
+380
-296
@@ -2,65 +2,67 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional, Sequence, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
ensure_physical_directory,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileChange:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[str]
|
||||
next_source: Optional[str]
|
||||
mode: int
|
||||
original_mode: Optional[int]
|
||||
original_device: Optional[int]
|
||||
original_inode: Optional[int]
|
||||
maximum_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilePrecondition:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[FileSnapshot]
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StagedChange:
|
||||
change: FileChange
|
||||
temporary_path: Optional[str]
|
||||
rollback_path: Optional[str]
|
||||
committed_snapshot: Optional[FileSnapshot] = None
|
||||
committed: bool = False
|
||||
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 _same_snapshot(
|
||||
current: Optional[FileSnapshot],
|
||||
expected: Optional[FileSnapshot],
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
def recover_pending_transactions() -> None:
|
||||
"""Recover a journal left by a terminated transaction process."""
|
||||
_recover_pending_transactions()
|
||||
|
||||
|
||||
def read_optional(
|
||||
@@ -80,24 +82,22 @@ def source_change(
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
expected_snapshot: object = _EXPECTED_SNAPSHOT_UNSET,
|
||||
enforce_mode: bool = False,
|
||||
) -> Optional[FileChange]:
|
||||
if original == next_source:
|
||||
return None
|
||||
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}"
|
||||
)
|
||||
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):
|
||||
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,
|
||||
@@ -117,6 +117,7 @@ def file_change(
|
||||
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(
|
||||
@@ -126,275 +127,358 @@ def file_change(
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
enforce_mode=enforce_mode,
|
||||
)
|
||||
|
||||
|
||||
def _remove_optional(file_path: Optional[str]) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
def _replace_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _cleanup_paths(paths: Sequence[Optional[str]]) -> List[str]:
|
||||
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 path in paths:
|
||||
for staged_item in reversed(staged):
|
||||
try:
|
||||
_remove_optional(path)
|
||||
_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 _write_staged(
|
||||
directory: str,
|
||||
basename: str,
|
||||
suffix: str,
|
||||
content: str,
|
||||
mode: int,
|
||||
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,
|
||||
) -> str:
|
||||
descriptor = -1
|
||||
temporary_path = ""
|
||||
try:
|
||||
descriptor, temporary_path = tempfile.mkstemp(
|
||||
prefix=f".{basename}.",
|
||||
suffix=suffix,
|
||||
dir=directory,
|
||||
text=True,
|
||||
)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file:
|
||||
descriptor = -1
|
||||
file.write(content)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.chmod(temporary_path, mode)
|
||||
return temporary_path
|
||||
except Exception as error:
|
||||
failures = []
|
||||
if descriptor >= 0:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError as close_error:
|
||||
failures.append(f"close failed: {close_error}")
|
||||
failures.extend(_cleanup_paths((temporary_path,)))
|
||||
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"Stage {label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _assert_unchanged(change: FileChange) -> None:
|
||||
snapshot = read_physical_file(
|
||||
change.file_path,
|
||||
change.label,
|
||||
change.maximum_bytes,
|
||||
)
|
||||
current = None if snapshot is None else snapshot.contents
|
||||
identity_changed = snapshot is not None and (
|
||||
snapshot.device != change.original_device
|
||||
or snapshot.inode != change.original_inode
|
||||
)
|
||||
if current != change.original or identity_changed:
|
||||
raise OSError(f"{change.label} changed during installation: {change.file_path}")
|
||||
|
||||
|
||||
def _assert_preconditions(
|
||||
preconditions: Sequence[FilePrecondition],
|
||||
operation: str,
|
||||
preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
) -> None:
|
||||
for condition in preconditions:
|
||||
current = read_physical_file(
|
||||
condition.file_path,
|
||||
condition.label,
|
||||
condition.maximum_bytes,
|
||||
)
|
||||
if not _same_snapshot(current, condition.original):
|
||||
raise OSError(
|
||||
f"{condition.label} changed during {operation}: "
|
||||
f"{condition.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _stage(change: FileChange) -> _StagedChange:
|
||||
_assert_unchanged(change)
|
||||
directory = os.path.dirname(change.file_path)
|
||||
ensure_physical_directory(directory, f"{change.label} directory")
|
||||
basename = os.path.basename(change.file_path)
|
||||
temporary_path = None
|
||||
rollback_path = 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:
|
||||
if change.next_source is not None:
|
||||
temporary_path = _write_staged(
|
||||
directory,
|
||||
basename,
|
||||
".tmp",
|
||||
change.next_source,
|
||||
change.mode,
|
||||
change.label,
|
||||
)
|
||||
if change.original is not None and change.next_source is not None:
|
||||
rollback_path = _write_staged(
|
||||
directory,
|
||||
basename,
|
||||
".rollback",
|
||||
change.original,
|
||||
change.original_mode or change.mode,
|
||||
f"{change.label} rollback",
|
||||
)
|
||||
elif change.original is not None:
|
||||
rollback_path = os.path.join(
|
||||
directory,
|
||||
f".{basename}.{uuid4().hex}.rollback",
|
||||
)
|
||||
if os.path.lexists(rollback_path):
|
||||
raise FileExistsError(f"Rollback path already exists: {rollback_path}")
|
||||
return _StagedChange(change, temporary_path, rollback_path)
|
||||
except Exception as error:
|
||||
failures = _cleanup_paths((temporary_path, rollback_path))
|
||||
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"Stage {change.label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _commit(staged: _StagedChange) -> None:
|
||||
_assert_unchanged(staged.change)
|
||||
if staged.change.next_source is None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
os.replace(staged.change.file_path, staged.rollback_path)
|
||||
else:
|
||||
if staged.temporary_path is None:
|
||||
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||
os.replace(staged.temporary_path, staged.change.file_path)
|
||||
staged.committed = True
|
||||
current = read_physical_file(
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
if staged.change.next_source is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} remained after removal: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
return
|
||||
if current is None or current.contents != staged.change.next_source:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed immediately after commit: "
|
||||
f"{staged.change.file_path}"
|
||||
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
|
||||
)
|
||||
staged.committed_snapshot = current
|
||||
|
||||
|
||||
def _assert_committed_unchanged(staged: _StagedChange) -> None:
|
||||
current = read_physical_file(
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
if staged.change.next_source is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
return
|
||||
committed = staged.committed_snapshot
|
||||
if current is None or committed is None or (
|
||||
current.contents != committed.contents
|
||||
or current.device != committed.device
|
||||
or current.inode != committed.inode
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _preserve_rollback(staged: _StagedChange, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
rollback_path = staged.rollback_path
|
||||
staged.rollback_path = None
|
||||
return OSError(
|
||||
f"{error}; original content preserved at {rollback_path}"
|
||||
)
|
||||
|
||||
|
||||
def _rollback(staged: _StagedChange) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
try:
|
||||
_assert_committed_unchanged(staged)
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
try:
|
||||
if staged.change.next_source is None or staged.change.original is not None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(
|
||||
f"Missing rollback data for {staged.change.label}"
|
||||
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,
|
||||
)
|
||||
os.replace(staged.rollback_path, staged.change.file_path)
|
||||
staged.rollback_path = None
|
||||
else:
|
||||
_remove_optional(staged.change.file_path)
|
||||
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:
|
||||
raise _preserve_rollback(staged, error) from 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
|
||||
|
||||
|
||||
def _cleanup(staged: _StagedChange) -> None:
|
||||
_remove_optional(staged.temporary_path)
|
||||
_remove_optional(staged.rollback_path)
|
||||
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:
|
||||
filtered = [change for change in changes if change.original != change.next_source]
|
||||
if not filtered:
|
||||
return
|
||||
paths = [os.path.normcase(os.path.abspath(change.file_path)) for change in filtered]
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ValueError(f"{label} contains duplicate file targets")
|
||||
staged: List[_StagedChange] = []
|
||||
try:
|
||||
_assert_preconditions(preconditions, label)
|
||||
for change in filtered:
|
||||
staged.append(_stage(change))
|
||||
_assert_preconditions(preconditions, label)
|
||||
for item in staged:
|
||||
_assert_preconditions(preconditions, label)
|
||||
_commit(item)
|
||||
_assert_preconditions(preconditions, label)
|
||||
except Exception as error:
|
||||
recovery_errors = []
|
||||
for item in reversed(staged):
|
||||
try:
|
||||
_rollback(item)
|
||||
except Exception as rollback_error:
|
||||
recovery_errors.append(str(rollback_error))
|
||||
for item in staged:
|
||||
try:
|
||||
_cleanup(item)
|
||||
except OSError as cleanup_error:
|
||||
recovery_errors.append(str(cleanup_error))
|
||||
suffix = (
|
||||
f"; recovery failed: {'; '.join(recovery_errors)}"
|
||||
if recovery_errors
|
||||
else ""
|
||||
with serialized_transactions():
|
||||
recover_pending_locked()
|
||||
_write_changes_locked(
|
||||
changes,
|
||||
label,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
opaque_removals,
|
||||
)
|
||||
raise OSError(f"{label}: {error}{suffix}") from error
|
||||
cleanup_errors = []
|
||||
for item in staged:
|
||||
try:
|
||||
_cleanup(item)
|
||||
except OSError as error:
|
||||
cleanup_errors.append(str(error))
|
||||
if cleanup_errors:
|
||||
raise OSError(f"{label} cleanup failed: {'; '.join(cleanup_errors)}")
|
||||
|
||||
|
||||
def regular_file_exists(file_path: str, label: str) -> bool:
|
||||
|
||||
Reference in new issue
Block a user