filetx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filetx/__init__.py +53 -0
- filetx/cli.py +109 -0
- filetx/errors.py +72 -0
- filetx/journal.py +195 -0
- filetx/ops.py +451 -0
- filetx/py.typed +0 -0
- filetx/transaction.py +346 -0
- filetx-0.1.0.dist-info/METADATA +234 -0
- filetx-0.1.0.dist-info/RECORD +12 -0
- filetx-0.1.0.dist-info/WHEEL +4 -0
- filetx-0.1.0.dist-info/entry_points.txt +2 -0
- filetx-0.1.0.dist-info/licenses/LICENSE +21 -0
filetx/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Transactional filesystem operations.
|
|
2
|
+
|
|
3
|
+
Batch moves, copies and deletes that either all commit or all roll back::
|
|
4
|
+
|
|
5
|
+
from filetx import Transaction
|
|
6
|
+
|
|
7
|
+
with Transaction(journal="release.jsonl") as tx:
|
|
8
|
+
tx.mkdir("dist", exist_ok=True)
|
|
9
|
+
tx.move("build/app.bin", "dist/app.bin")
|
|
10
|
+
tx.delete("build", missing_ok=True)
|
|
11
|
+
|
|
12
|
+
If any step fails, every completed step is undone and the tree is left as it
|
|
13
|
+
was found. If the process is killed part-way through, ``filetx undo
|
|
14
|
+
release.jsonl`` finishes the rollback from the journal.
|
|
15
|
+
|
|
16
|
+
See :class:`Transaction` for the API, :mod:`filetx.ops` for how reversibility
|
|
17
|
+
is implemented, and :mod:`filetx.journal` for the on-disk format.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .errors import (
|
|
21
|
+
FiletxError,
|
|
22
|
+
JournalError,
|
|
23
|
+
OperationError,
|
|
24
|
+
RollbackError,
|
|
25
|
+
TransactionError,
|
|
26
|
+
)
|
|
27
|
+
from .journal import JOURNAL_VERSION, Journal, JournalFile, read_journal
|
|
28
|
+
from .ops import STAGE_PREFIX, Copy, Delete, Mkdir, Move, Operation
|
|
29
|
+
from .transaction import Recovery, Transaction, recover
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"JOURNAL_VERSION",
|
|
35
|
+
"STAGE_PREFIX",
|
|
36
|
+
"Copy",
|
|
37
|
+
"Delete",
|
|
38
|
+
"FiletxError",
|
|
39
|
+
"Journal",
|
|
40
|
+
"JournalError",
|
|
41
|
+
"JournalFile",
|
|
42
|
+
"Mkdir",
|
|
43
|
+
"Move",
|
|
44
|
+
"Operation",
|
|
45
|
+
"OperationError",
|
|
46
|
+
"Recovery",
|
|
47
|
+
"RollbackError",
|
|
48
|
+
"Transaction",
|
|
49
|
+
"TransactionError",
|
|
50
|
+
"__version__",
|
|
51
|
+
"read_journal",
|
|
52
|
+
"recover",
|
|
53
|
+
]
|
filetx/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
The library is the product; the CLI exists for the one job you cannot do from
|
|
4
|
+
inside the crashed process -- finishing an interrupted transaction after the
|
|
5
|
+
fact::
|
|
6
|
+
|
|
7
|
+
filetx inspect release.jsonl # what was this transaction doing?
|
|
8
|
+
filetx undo release.jsonl # put it back
|
|
9
|
+
|
|
10
|
+
Exit codes: ``0`` success, ``1`` recovery failed or the journal is unusable,
|
|
11
|
+
``2`` bad arguments (argparse's own convention).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .errors import FiletxError, RollbackError
|
|
20
|
+
from .journal import read_journal
|
|
21
|
+
from .transaction import recover
|
|
22
|
+
|
|
23
|
+
__all__ = ["main"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="filetx",
|
|
29
|
+
description="Inspect and recover filetx transaction journals.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"filetx {__version__}")
|
|
32
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
undo = sub.add_parser("undo", help="finish an interrupted transaction from its journal")
|
|
35
|
+
undo.add_argument("journal", help="path to the journal file")
|
|
36
|
+
undo.add_argument(
|
|
37
|
+
"--no-fsync",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="skip fsync when settling the journal (faster, less durable)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
inspect = sub.add_parser("inspect", help="show a journal's plan and status")
|
|
43
|
+
inspect.add_argument("journal", help="path to the journal file")
|
|
44
|
+
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
49
|
+
journal = read_journal(args.journal)
|
|
50
|
+
status = journal.terminal or "INTERRUPTED"
|
|
51
|
+
print(f"transaction {journal.txid} journal v{journal.version} status: {status}")
|
|
52
|
+
print(f"{len(journal.plan)} operation(s) planned:")
|
|
53
|
+
for index, op in enumerate(journal.plan):
|
|
54
|
+
if index in journal.completed:
|
|
55
|
+
mark = "done"
|
|
56
|
+
elif index in journal.attempted:
|
|
57
|
+
mark = "PARTIAL"
|
|
58
|
+
else:
|
|
59
|
+
mark = "-"
|
|
60
|
+
print(f" [{index:>3}] {mark:<8} {op.describe()}")
|
|
61
|
+
if not journal.settled:
|
|
62
|
+
print("\nThis transaction never settled. Run `filetx undo` to roll it back.")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cmd_undo(args: argparse.Namespace) -> int:
|
|
67
|
+
result = recover(args.journal, fsync=not args.no_fsync)
|
|
68
|
+
|
|
69
|
+
if result.action == "rolled-back":
|
|
70
|
+
print(f"rolled back transaction {result.txid}: {len(result.reverted)} operation(s) undone")
|
|
71
|
+
elif result.action == "purged":
|
|
72
|
+
print(f"transaction {result.txid} had committed; cleaned up {len(result.purged)} entry(s)")
|
|
73
|
+
else:
|
|
74
|
+
print(f"transaction {result.txid} was already settled; nothing to do")
|
|
75
|
+
|
|
76
|
+
if result.leftover:
|
|
77
|
+
print(
|
|
78
|
+
f"\n{len(result.leftover)} staged entry(s) were kept rather than deleted, because "
|
|
79
|
+
f"their original paths are occupied again and these may be the only copies left:"
|
|
80
|
+
)
|
|
81
|
+
for path in result.leftover:
|
|
82
|
+
print(f" {path}")
|
|
83
|
+
print("Review them, then remove them by hand.")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
88
|
+
"""Entry point. Returns the process exit code."""
|
|
89
|
+
args = _build_parser().parse_args(argv)
|
|
90
|
+
handlers = {"inspect": _cmd_inspect, "undo": _cmd_undo}
|
|
91
|
+
try:
|
|
92
|
+
return handlers[args.command](args)
|
|
93
|
+
except RollbackError as exc:
|
|
94
|
+
print(f"filetx: {exc}", file=sys.stderr)
|
|
95
|
+
for index, failure in exc.failures:
|
|
96
|
+
print(f" operation {index}: {failure}", file=sys.stderr)
|
|
97
|
+
print(
|
|
98
|
+
"The journal was left unsettled. Fix the underlying problem and run "
|
|
99
|
+
"`filetx undo` again.",
|
|
100
|
+
file=sys.stderr,
|
|
101
|
+
)
|
|
102
|
+
return 1
|
|
103
|
+
except FiletxError as exc:
|
|
104
|
+
print(f"filetx: {exc}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__": # pragma: no cover
|
|
109
|
+
raise SystemExit(main())
|
filetx/errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
The distinction that matters is :class:`TransactionError` versus
|
|
4
|
+
:class:`RollbackError`. The first means "your change did not happen, and the
|
|
5
|
+
tree is as you left it" -- recoverable, and the caller can retry. The second
|
|
6
|
+
means "your change did not happen and undoing it *also* failed" -- the tree is
|
|
7
|
+
in an indeterminate state and a human has to look at it. Collapsing those two
|
|
8
|
+
into one exception type would be the single most dangerous thing this library
|
|
9
|
+
could do, so they are separate and ``RollbackError`` carries the failures.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"FiletxError",
|
|
14
|
+
"JournalError",
|
|
15
|
+
"OperationError",
|
|
16
|
+
"RollbackError",
|
|
17
|
+
"TransactionError",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FiletxError(Exception):
|
|
22
|
+
"""Base class for every error raised by filetx."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OperationError(FiletxError):
|
|
26
|
+
"""A single operation could not be applied.
|
|
27
|
+
|
|
28
|
+
Raised during :meth:`filetx.Transaction.commit` and normally wrapped in a
|
|
29
|
+
:class:`TransactionError` after the rollback has completed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TransactionError(FiletxError):
|
|
34
|
+
"""A commit failed and was rolled back successfully.
|
|
35
|
+
|
|
36
|
+
The filesystem is back in its pre-commit state. The underlying cause is
|
|
37
|
+
available as ``__cause__`` and as :attr:`cause`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, *, index: int, cause: BaseException) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.index = index
|
|
43
|
+
"""Position in the plan of the operation that failed."""
|
|
44
|
+
self.cause = cause
|
|
45
|
+
"""The exception that caused the failure."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RollbackError(FiletxError):
|
|
49
|
+
"""A commit failed *and* the rollback failed.
|
|
50
|
+
|
|
51
|
+
The filesystem is in an indeterminate state: some operations were undone
|
|
52
|
+
and some were not. :attr:`failures` lists what could not be undone, and
|
|
53
|
+
:attr:`journal` points at the journal file to hand to ``filetx undo``
|
|
54
|
+
once the underlying problem is fixed.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
message: str,
|
|
60
|
+
*,
|
|
61
|
+
failures: list[tuple[int, BaseException]],
|
|
62
|
+
journal: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.failures = failures
|
|
66
|
+
"""``(plan index, exception)`` for each operation that could not be undone."""
|
|
67
|
+
self.journal = journal
|
|
68
|
+
"""Path to the journal file, if the transaction was journalled."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class JournalError(FiletxError):
|
|
72
|
+
"""A journal file is missing, unreadable, or written by an incompatible version."""
|
filetx/journal.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Write-ahead journal.
|
|
2
|
+
|
|
3
|
+
The journal is what makes a crash survivable. Each record is one JSON object
|
|
4
|
+
on its own line, flushed and ``fsync``-ed *before* the filesystem change it
|
|
5
|
+
describes is attempted. The journal on disk is therefore never behind reality
|
|
6
|
+
-- at worst it records an operation as attempted that did not in fact happen,
|
|
7
|
+
which is the safe direction to be wrong in, because every ``revert`` checks
|
|
8
|
+
the real filesystem before acting.
|
|
9
|
+
|
|
10
|
+
Record sequence for a transaction::
|
|
11
|
+
|
|
12
|
+
{"event": "begin", "version": 1, "tx": "...", "plan": [...]}
|
|
13
|
+
{"event": "apply", "index": 0, "undo": {...}}
|
|
14
|
+
{"event": "done", "index": 0}
|
|
15
|
+
{"event": "apply", "index": 1, "undo": {...}}
|
|
16
|
+
{"event": "fail", "index": 1, "error": "..."}
|
|
17
|
+
{"event": "rollback"}
|
|
18
|
+
|
|
19
|
+
A journal whose last record is ``commit`` or ``rollback`` is *settled*.
|
|
20
|
+
Anything else is an interrupted transaction that :func:`filetx.recover` can
|
|
21
|
+
finish, using the undo records already on disk.
|
|
22
|
+
|
|
23
|
+
JSON Lines rather than a binary format or SQLite: an operator staring at a
|
|
24
|
+
half-finished batch can read it with ``cat``, and appending one short line is
|
|
25
|
+
already atomic enough for records this size.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from types import TracebackType
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from .errors import JournalError
|
|
37
|
+
from .ops import Operation, UndoRecord, operation_from_dict
|
|
38
|
+
|
|
39
|
+
__all__ = ["JOURNAL_VERSION", "Journal", "JournalFile", "read_journal"]
|
|
40
|
+
|
|
41
|
+
JOURNAL_VERSION = 1
|
|
42
|
+
"""Bumped only on a breaking change to the record format."""
|
|
43
|
+
|
|
44
|
+
SETTLED = ("commit", "rollback")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Journal:
|
|
48
|
+
"""Append-only writer for one transaction's journal.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path: File to append to. Created if missing.
|
|
52
|
+
fsync: Force each record to stable storage before returning. Correct
|
|
53
|
+
by default, and substantially slower than buffered writes. Turn it
|
|
54
|
+
off only when the journal is a debugging aid rather than a
|
|
55
|
+
recovery mechanism.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, path: str | os.PathLike[str], *, fsync: bool = True) -> None:
|
|
59
|
+
self.path = Path(path)
|
|
60
|
+
self.fsync = fsync
|
|
61
|
+
try:
|
|
62
|
+
self._fh = self.path.open("a", encoding="utf-8", newline="\n")
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise JournalError(f"cannot open journal {self.path}: {exc}") from exc
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> "Journal":
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(
|
|
70
|
+
self,
|
|
71
|
+
exc_type: type[BaseException] | None,
|
|
72
|
+
exc: BaseException | None,
|
|
73
|
+
tb: TracebackType | None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Close the underlying file. Safe to call more than once."""
|
|
79
|
+
if not self._fh.closed:
|
|
80
|
+
self._fh.close()
|
|
81
|
+
|
|
82
|
+
def _write(self, record: dict[str, Any]) -> None:
|
|
83
|
+
record["ts"] = time.time()
|
|
84
|
+
self._fh.write(json.dumps(record, sort_keys=True) + "\n")
|
|
85
|
+
self._fh.flush()
|
|
86
|
+
if self.fsync:
|
|
87
|
+
os.fsync(self._fh.fileno())
|
|
88
|
+
|
|
89
|
+
def begin(self, txid: str, plan: list[Operation]) -> None:
|
|
90
|
+
"""Record the full plan before any of it is attempted."""
|
|
91
|
+
self._write(
|
|
92
|
+
{
|
|
93
|
+
"event": "begin",
|
|
94
|
+
"version": JOURNAL_VERSION,
|
|
95
|
+
"tx": txid,
|
|
96
|
+
"plan": [op.to_dict() for op in plan],
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def apply(self, index: int, undo: UndoRecord) -> None:
|
|
101
|
+
"""Record how to undo an operation, before it is attempted."""
|
|
102
|
+
self._write({"event": "apply", "index": index, "undo": undo})
|
|
103
|
+
|
|
104
|
+
def done(self, index: int) -> None:
|
|
105
|
+
"""Record that an operation completed."""
|
|
106
|
+
self._write({"event": "done", "index": index})
|
|
107
|
+
|
|
108
|
+
def fail(self, index: int, error: str) -> None:
|
|
109
|
+
"""Record that an operation failed."""
|
|
110
|
+
self._write({"event": "fail", "index": index, "error": error})
|
|
111
|
+
|
|
112
|
+
def commit(self) -> None:
|
|
113
|
+
"""Settle the journal: the transaction is permanent."""
|
|
114
|
+
self._write({"event": "commit"})
|
|
115
|
+
|
|
116
|
+
def rollback(self) -> None:
|
|
117
|
+
"""Settle the journal: the transaction was undone."""
|
|
118
|
+
self._write({"event": "rollback"})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class JournalFile:
|
|
123
|
+
"""A parsed journal."""
|
|
124
|
+
|
|
125
|
+
path: Path
|
|
126
|
+
txid: str
|
|
127
|
+
version: int
|
|
128
|
+
plan: list[Operation]
|
|
129
|
+
attempted: dict[int, UndoRecord] = field(default_factory=dict)
|
|
130
|
+
"""Undo records for every operation that was started, keyed by plan index.
|
|
131
|
+
An operation is in here whether or not it finished -- that is the point."""
|
|
132
|
+
completed: set[int] = field(default_factory=set)
|
|
133
|
+
"""Operations that ran to completion."""
|
|
134
|
+
terminal: str | None = None
|
|
135
|
+
"""``"commit"``, ``"rollback"``, or ``None`` if the transaction never settled."""
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def settled(self) -> bool:
|
|
139
|
+
"""True if the transaction reached a definite end."""
|
|
140
|
+
return self.terminal in SETTLED
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_journal(path: str | os.PathLike[str]) -> JournalFile:
|
|
144
|
+
"""Parse a journal file.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
JournalError: The file is missing, malformed, has no ``begin`` record,
|
|
148
|
+
or was written by an incompatible journal version.
|
|
149
|
+
"""
|
|
150
|
+
path = Path(path)
|
|
151
|
+
try:
|
|
152
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
raise JournalError(f"cannot read journal {path}: {exc}") from exc
|
|
155
|
+
|
|
156
|
+
records: list[dict[str, Any]] = []
|
|
157
|
+
for lineno, line in enumerate(lines, start=1):
|
|
158
|
+
if not line.strip():
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
records.append(json.loads(line))
|
|
162
|
+
except json.JSONDecodeError as exc:
|
|
163
|
+
# A torn last line means the process died mid-write. Everything
|
|
164
|
+
# before it is still valid, so keep that and stop.
|
|
165
|
+
if lineno == len(lines):
|
|
166
|
+
break
|
|
167
|
+
raise JournalError(f"{path}:{lineno}: malformed journal record: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
if not records or records[0].get("event") != "begin":
|
|
170
|
+
raise JournalError(f"{path}: no begin record -- not a filetx journal")
|
|
171
|
+
|
|
172
|
+
begin = records[0]
|
|
173
|
+
version = begin.get("version")
|
|
174
|
+
if version != JOURNAL_VERSION:
|
|
175
|
+
raise JournalError(
|
|
176
|
+
f"{path}: journal version {version!r}, this filetx understands {JOURNAL_VERSION}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
journal = JournalFile(
|
|
180
|
+
path=path,
|
|
181
|
+
txid=begin.get("tx", ""),
|
|
182
|
+
version=version,
|
|
183
|
+
plan=[operation_from_dict(d) for d in begin.get("plan", [])],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
for record in records[1:]:
|
|
187
|
+
event = record.get("event")
|
|
188
|
+
if event == "apply":
|
|
189
|
+
journal.attempted[record["index"]] = record.get("undo", {})
|
|
190
|
+
elif event == "done":
|
|
191
|
+
journal.completed.add(record["index"])
|
|
192
|
+
elif event in SETTLED:
|
|
193
|
+
journal.terminal = event
|
|
194
|
+
|
|
195
|
+
return journal
|