filetx 0.1.0__tar.gz
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-0.1.0/.gitignore +46 -0
- filetx-0.1.0/CHANGELOG.md +48 -0
- filetx-0.1.0/LICENSE +21 -0
- filetx-0.1.0/PKG-INFO +234 -0
- filetx-0.1.0/README.md +209 -0
- filetx-0.1.0/pyproject.toml +70 -0
- filetx-0.1.0/src/filetx/__init__.py +53 -0
- filetx-0.1.0/src/filetx/cli.py +109 -0
- filetx-0.1.0/src/filetx/errors.py +72 -0
- filetx-0.1.0/src/filetx/journal.py +195 -0
- filetx-0.1.0/src/filetx/ops.py +451 -0
- filetx-0.1.0/src/filetx/py.typed +0 -0
- filetx-0.1.0/src/filetx/transaction.py +346 -0
- filetx-0.1.0/tests/conftest.py +127 -0
- filetx-0.1.0/tests/test_cli.py +98 -0
- filetx-0.1.0/tests/test_edge_cases.py +181 -0
- filetx-0.1.0/tests/test_journal.py +115 -0
- filetx-0.1.0/tests/test_operations.py +235 -0
- filetx-0.1.0/tests/test_recovery.py +109 -0
- filetx-0.1.0/tests/test_transaction.py +169 -0
filetx-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Environments & secrets
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
*.pem
|
|
5
|
+
secrets/
|
|
6
|
+
|
|
7
|
+
# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
dist/
|
|
18
|
+
build/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# Notebooks
|
|
22
|
+
.ipynb_checkpoints/
|
|
23
|
+
|
|
24
|
+
# Data — commit only small samples, never full datasets
|
|
25
|
+
data/raw/
|
|
26
|
+
data/interim/
|
|
27
|
+
data/processed/
|
|
28
|
+
*.parquet
|
|
29
|
+
*.duckdb
|
|
30
|
+
*.db
|
|
31
|
+
*.sqlite3
|
|
32
|
+
!data/sample/**
|
|
33
|
+
|
|
34
|
+
# Models & artifacts
|
|
35
|
+
models/
|
|
36
|
+
*.pt
|
|
37
|
+
*.pth
|
|
38
|
+
*.onnx
|
|
39
|
+
*.pkl
|
|
40
|
+
mlruns/
|
|
41
|
+
|
|
42
|
+
# OS / editor
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
.vscode/
|
|
46
|
+
.idea/
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
The public API is everything exported from `filetx.__all__`. Anything reachable
|
|
9
|
+
only through a private name or a submodule attribute is not covered by the
|
|
10
|
+
compatibility promise below.
|
|
11
|
+
|
|
12
|
+
## [Unreleased]
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-08-29
|
|
15
|
+
|
|
16
|
+
Initial release.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `Transaction` — record a batch of filesystem operations and apply them
|
|
21
|
+
all-or-nothing. Usable as a context manager: commits on clean exit, discards
|
|
22
|
+
the plan if the block raises.
|
|
23
|
+
- Operations: `Move`, `Copy`, `Delete`, `Mkdir`. Custom operations can be added
|
|
24
|
+
via `Transaction.add` by subclassing `Operation`.
|
|
25
|
+
- Reversible deletes and overwrites via same-filesystem staging: the target is
|
|
26
|
+
renamed to a hidden sibling rather than removed, so making a delete undoable
|
|
27
|
+
is one atomic rename regardless of size.
|
|
28
|
+
- Write-ahead journal (`Journal`, `read_journal`, JSON Lines). Each undo record
|
|
29
|
+
is written and `fsync`-ed before the change it describes is attempted, so a
|
|
30
|
+
process killed mid-commit leaves a recoverable journal.
|
|
31
|
+
- `recover()` and the `filetx undo` / `filetx inspect` CLI for finishing an
|
|
32
|
+
interrupted transaction after the fact.
|
|
33
|
+
- Distinct `TransactionError` (rolled back cleanly) and `RollbackError`
|
|
34
|
+
(rollback failed, tree indeterminate) so callers can tell the difference.
|
|
35
|
+
- Cross-filesystem moves degrade to copy-then-stage on `EXDEV`; every other
|
|
36
|
+
`OSError` propagates.
|
|
37
|
+
- Full type hints and a `py.typed` marker.
|
|
38
|
+
|
|
39
|
+
### Notes
|
|
40
|
+
|
|
41
|
+
- Zero runtime dependencies. Supports Python 3.10–3.13.
|
|
42
|
+
- No concurrency control: transactions touching overlapping paths must be
|
|
43
|
+
serialised by the caller.
|
|
44
|
+
- Recovery reports, and never deletes, staged data whose original path has been
|
|
45
|
+
reoccupied.
|
|
46
|
+
|
|
47
|
+
[Unreleased]: https://github.com/Prithv122/filetx/compare/v0.1.0...HEAD
|
|
48
|
+
[0.1.0]: https://github.com/Prithv122/filetx/releases/tag/v0.1.0
|
filetx-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prithvi M
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
filetx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: filetx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transactional filesystem operations - batch moves, copies and deletes that either all commit or all roll back.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Prithv122/filetx
|
|
6
|
+
Project-URL: Source, https://github.com/Prithv122/filetx
|
|
7
|
+
Project-URL: Issues, https://github.com/Prithv122/filetx/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Prithv122/filetx/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Prithvi M <feverishpetroleum16@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: atomic,files,filesystem,rollback,transaction,undo
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Filesystems
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# filetx
|
|
27
|
+
|
|
28
|
+
> Transactional filesystem operations — batch moves, copies and deletes that either all commit or all roll back.
|
|
29
|
+
|
|
30
|
+
[](https://github.com/Prithv122/filetx/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/filetx/)
|
|
32
|
+
[](https://pypi.org/project/filetx/)
|
|
33
|
+
|
|
34
|
+
**Stack:** Python 3.10+, standard library only, zero runtime dependencies.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install filetx
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from filetx import Transaction
|
|
42
|
+
|
|
43
|
+
with Transaction(journal="release.jsonl") as tx:
|
|
44
|
+
tx.mkdir("dist", exist_ok=True)
|
|
45
|
+
tx.move("build/app.bin", "dist/app.bin")
|
|
46
|
+
tx.copy("build/manifest.json", "dist/manifest.json")
|
|
47
|
+
tx.delete("build", missing_ok=True)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Nothing happens until the block exits. If any step fails, every completed step
|
|
51
|
+
is undone. If the process is killed part-way through, `filetx undo release.jsonl`
|
|
52
|
+
finishes the rollback from the journal it left behind.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 1. The problem
|
|
57
|
+
|
|
58
|
+
Multi-step filesystem work has no rollback. A release script that creates a
|
|
59
|
+
directory, moves three artefacts into it and deletes the build tree has four
|
|
60
|
+
chances to fail, and if it fails on the third the disk is left in a state that
|
|
61
|
+
matches neither the before nor the after. The usual responses are to copy
|
|
62
|
+
everything to a backup directory first — which costs time proportional to the
|
|
63
|
+
data, so nobody does it for large trees — or to write bespoke cleanup code per
|
|
64
|
+
script, which is only exercised on the day it is needed and is therefore
|
|
65
|
+
usually wrong.
|
|
66
|
+
|
|
67
|
+
Python has `shutil` for individual operations and `atomicwrites` for making a
|
|
68
|
+
single file write atomic. Neither gives you a *batch* that is all-or-nothing.
|
|
69
|
+
`filetx` is that missing piece: record the operations, commit them, and get the
|
|
70
|
+
tree back unchanged if anything goes wrong — including if the process dies.
|
|
71
|
+
|
|
72
|
+
This started as the transaction log inside a file-organiser tool I built, where
|
|
73
|
+
a half-finished reorganisation of someone's photo library is a genuinely bad
|
|
74
|
+
outcome. The engine turned out to be the interesting part, so it was extracted,
|
|
75
|
+
generalised and published.
|
|
76
|
+
|
|
77
|
+
## 2. What it operates on
|
|
78
|
+
|
|
79
|
+
There is no dataset here — this is a library, and its input is whatever tree the
|
|
80
|
+
caller points it at.
|
|
81
|
+
|
|
82
|
+
| | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| Input | Arbitrary files, directories and symlinks on a local filesystem |
|
|
85
|
+
| Scale tested | Trees up to 5,000 files; single renames are size-independent |
|
|
86
|
+
| Benchmark data | Synthetic, generated by `scripts/benchmark.py` (seed 42), not committed |
|
|
87
|
+
| Package licence | MIT |
|
|
88
|
+
| Dependencies | None at runtime. `pytest`, `pytest-cov` and `ruff` for development |
|
|
89
|
+
|
|
90
|
+
**Non-goals.** Not a distributed transaction manager, not crash-safe against
|
|
91
|
+
media failure, and not safe against another process mutating the same paths
|
|
92
|
+
concurrently — see [Limitations](#limitations).
|
|
93
|
+
|
|
94
|
+
## 3. Architecture
|
|
95
|
+
|
|
96
|
+
```mermaid
|
|
97
|
+
flowchart TD
|
|
98
|
+
A["tx.move / copy / delete / mkdir<br/>(recorded, not performed)"] --> B["commit()"]
|
|
99
|
+
B --> C["plan_undo — validate,<br/>compute undo record"]
|
|
100
|
+
C -->|invalid| R
|
|
101
|
+
C --> D["journal — write undo record, fsync"]
|
|
102
|
+
D --> E["apply — change the filesystem"]
|
|
103
|
+
E -->|OSError| R
|
|
104
|
+
E --> F{"more operations?"}
|
|
105
|
+
F -->|yes| C
|
|
106
|
+
F -->|no| G["journal — commit"]
|
|
107
|
+
G --> H["purge staged material"]
|
|
108
|
+
R["revert applied operations,<br/>in reverse order"] --> S{"all reverted?"}
|
|
109
|
+
S -->|yes| T["journal — rollback<br/>raise TransactionError"]
|
|
110
|
+
S -->|no| U["journal left unsettled<br/>raise RollbackError"]
|
|
111
|
+
U -.->|"later: filetx undo"| R
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The undo record is written to the journal **before** the change it describes is
|
|
115
|
+
attempted. That is the whole design: a record written afterwards is useless to a
|
|
116
|
+
process that died in between.
|
|
117
|
+
|
|
118
|
+
Writing it first requires knowing, in advance, where a file is about to be moved
|
|
119
|
+
to — so staged names are derived deterministically from `(path, transaction id,
|
|
120
|
+
plan index)` rather than allocated at random.
|
|
121
|
+
|
|
122
|
+
The remaining ambiguity is that the journal can describe a change that never
|
|
123
|
+
actually happened. Every `revert` therefore inspects the real filesystem before
|
|
124
|
+
acting instead of trusting the record, which makes reverting idempotent: running
|
|
125
|
+
it twice, or against an operation that never applied, does nothing.
|
|
126
|
+
|
|
127
|
+
## 4. Key decisions & tradeoffs
|
|
128
|
+
|
|
129
|
+
| Decision | Chose | Over | Why |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| Making deletes reversible | Rename the target to a hidden sibling in its own parent directory | Copy it to a staging/backup directory | A sibling is guaranteed to be on the same filesystem, so it is one atomic rename regardless of size — **1,280× faster** on a 5,000-file tree (§5). It also nests correctly: deleting `a/b.txt` then `a` stages the file inside `a`, then renames `a` wholesale, and reverse-order rollback restores `a` before looking for `b.txt` inside it. |
|
|
132
|
+
| When to compute the undo record | Before applying (write-ahead) | After applying, returning what was done | The "after" version cannot recover a process killed between the change and the log write — precisely the window that matters. Cost: staged names must be deterministic, and the journal may record changes that never happened, which is why every revert is state-checking. |
|
|
133
|
+
| Operations execute | Deferred — recorded, applied on `commit()` | Eagerly, as each method is called | Makes the plan inspectable and gives a dry run (`tx.describe()`) for free, and an exception in the caller's own code inside the `with` block costs nothing to undo. |
|
|
134
|
+
| Failure signalling | `TransactionError` and `RollbackError` as distinct types | One exception for "the commit failed" | "Your change didn't happen and the tree is fine" and "your change didn't happen and the tree is in an unknown state" demand completely different responses. Collapsing them would be the most dangerous thing this library could do. |
|
|
135
|
+
| Recovery vs. leftover staged data | Report it, never delete it | Clean up automatically | If a revert declined to restore something because the original path is occupied again, the staged copy may be the only copy left. Unattended deletion there is the one unrecoverable mistake available; `filetx undo` prints the paths and stops. |
|
|
136
|
+
| Cross-filesystem moves | Detect `EXDEV`, degrade to copy-then-stage | Refuse, or always copy | Keeps the fast path fast and the slow path correct. Documented as O(size) rather than O(1). Any other `OSError` propagates — a permission error must not be silently treated as a volume boundary. |
|
|
137
|
+
| Journal format | JSON Lines | SQLite, or a binary log | An operator looking at a half-finished batch can read it with `cat`. Appending one short line is atomic enough at this size, and a torn final line is detected and tolerated. |
|
|
138
|
+
|
|
139
|
+
## 5. Results
|
|
140
|
+
|
|
141
|
+
Measured on Windows 11 (NTFS), Python 3.13.9, 12-core CPU / 64 GB RAM, median of
|
|
142
|
+
3 runs. Reproduce with `uv run python scripts/benchmark.py --files 5000 --size 1024`.
|
|
143
|
+
|
|
144
|
+
| Metric | Value | Notes |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| Make a 5,000-file delete reversible, then undo it | **7.6 ms** | Two renames |
|
|
147
|
+
| Same outcome via copy-to-backup then restore | **9,692 ms** | `shutil.copytree` + `rmtree` + `copytree` |
|
|
148
|
+
| Speedup on the reversibility path | **~1,280×** | Widens with tree size; staging is size-independent |
|
|
149
|
+
| Commit the same delete (data actually removed) | 1,407 ms | Staging is O(1), *deleting* is not — stated so the number above is not mistaken for magic |
|
|
150
|
+
| Tests | **73 passing, 100% line coverage** | Includes two tests that `os._exit()` a real subprocess mid-commit, on either side of the rename, then recover from the journal |
|
|
151
|
+
| Python versions | 3.10, 3.11, 3.12, 3.13 | All four run in CI, not just claimed in metadata |
|
|
152
|
+
| Platforms | Linux, Windows, macOS | Windows and macOS on 3.13; this library is mostly `os.rename`, which is where platforms disagree |
|
|
153
|
+
| Runtime dependencies | 0 | |
|
|
154
|
+
|
|
155
|
+
The benchmark tree is synthetic and generated by the script above. The
|
|
156
|
+
comparison is against `shutil`, i.e. what you would write by hand, not against
|
|
157
|
+
another library.
|
|
158
|
+
|
|
159
|
+
## 6. How to run
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
git clone https://github.com/Prithv122/filetx.git
|
|
163
|
+
cd filetx
|
|
164
|
+
uv sync
|
|
165
|
+
uv run pytest --cov=src --cov-report=term-missing
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
No environment variables, services or datasets are needed. Other useful commands:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
uv run python scripts/benchmark.py # reproduce the numbers in section 5
|
|
172
|
+
uv run ruff check . && uv run ruff format --check .
|
|
173
|
+
uv build && uv run --with twine twine check --strict dist/*
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Recovering an interrupted run
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
filetx inspect release.jsonl # what was this transaction doing, and how far did it get?
|
|
180
|
+
filetx undo release.jsonl # put it back
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`inspect` marks each operation `done`, `PARTIAL` or `-`. `undo` rolls an
|
|
184
|
+
unsettled transaction back, cleans up after a committed one, and reports — but
|
|
185
|
+
never deletes — staged data whose original path has since been reoccupied.
|
|
186
|
+
|
|
187
|
+
### Limitations
|
|
188
|
+
|
|
189
|
+
- **No locking.** Two transactions touching the same paths concurrently will
|
|
190
|
+
interfere. Serialise them yourself.
|
|
191
|
+
- **`fsync` durability only.** The journal is `fsync`-ed before each change, so
|
|
192
|
+
a process kill or power loss is recoverable, but a lying disk cache or media
|
|
193
|
+
failure is not. Pass `fsync=False` to trade that away for speed.
|
|
194
|
+
- **Staged material is visible.** During a transaction, `.filetx-*` entries
|
|
195
|
+
exist alongside your files. A crash leaves them until `filetx undo` runs;
|
|
196
|
+
`filetx.STAGE_PREFIX` is exported so scanners can skip them.
|
|
197
|
+
- **Cross-filesystem moves are O(size).** They fall back to copying.
|
|
198
|
+
|
|
199
|
+
## 7. What I'd change at 100× scale
|
|
200
|
+
|
|
201
|
+
The design assumes a batch you can hold in memory and a journal you read whole.
|
|
202
|
+
At 100× — hundreds of thousands of operations per transaction — three things
|
|
203
|
+
break, in this order:
|
|
204
|
+
|
|
205
|
+
1. **The plan is written to the journal as one record.** A single JSON line
|
|
206
|
+
holding 500k operations has to be serialised and `fsync`-ed before the first
|
|
207
|
+
change happens, and re-parsed in full during recovery. I would stream the
|
|
208
|
+
plan as one record per operation and make `read_journal` incremental.
|
|
209
|
+
2. **`fsync` per operation dominates.** At ~1 ms per flush, 500k operations is
|
|
210
|
+
over eight minutes of waiting on the disk. I would batch the flush — group
|
|
211
|
+
commits of N records — accepting a bounded window where the journal lags
|
|
212
|
+
reality, and handle it by making recovery re-verify the tail against the
|
|
213
|
+
filesystem, which the state-checking reverts already support.
|
|
214
|
+
3. **Rollback is serial.** Undoing 500k renames one at a time wastes an SSD's
|
|
215
|
+
parallelism. Reverts of operations on disjoint paths could run in a thread
|
|
216
|
+
pool; the ordering constraint is only between operations whose paths nest,
|
|
217
|
+
which is a partial order the planner could compute up front.
|
|
218
|
+
|
|
219
|
+
What I would *not* change is the staging strategy — it is the part that gets
|
|
220
|
+
better with scale, not worse.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## References
|
|
225
|
+
|
|
226
|
+
The write-ahead ordering and the do/undo/purge split follow standard database
|
|
227
|
+
recovery practice; ARIES (Mohan et al., 1992) is the canonical description, and
|
|
228
|
+
the naming of the three phases here is deliberately borrowed from it. No
|
|
229
|
+
implementation was consulted — the filesystem constraints are different enough
|
|
230
|
+
that the resemblance is conceptual.
|
|
231
|
+
|
|
232
|
+
## Licence
|
|
233
|
+
|
|
234
|
+
MIT — see [LICENSE](LICENSE).
|
filetx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# filetx
|
|
2
|
+
|
|
3
|
+
> Transactional filesystem operations — batch moves, copies and deletes that either all commit or all roll back.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/Prithv122/filetx/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/filetx/)
|
|
7
|
+
[](https://pypi.org/project/filetx/)
|
|
8
|
+
|
|
9
|
+
**Stack:** Python 3.10+, standard library only, zero runtime dependencies.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install filetx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from filetx import Transaction
|
|
17
|
+
|
|
18
|
+
with Transaction(journal="release.jsonl") as tx:
|
|
19
|
+
tx.mkdir("dist", exist_ok=True)
|
|
20
|
+
tx.move("build/app.bin", "dist/app.bin")
|
|
21
|
+
tx.copy("build/manifest.json", "dist/manifest.json")
|
|
22
|
+
tx.delete("build", missing_ok=True)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Nothing happens until the block exits. If any step fails, every completed step
|
|
26
|
+
is undone. If the process is killed part-way through, `filetx undo release.jsonl`
|
|
27
|
+
finishes the rollback from the journal it left behind.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 1. The problem
|
|
32
|
+
|
|
33
|
+
Multi-step filesystem work has no rollback. A release script that creates a
|
|
34
|
+
directory, moves three artefacts into it and deletes the build tree has four
|
|
35
|
+
chances to fail, and if it fails on the third the disk is left in a state that
|
|
36
|
+
matches neither the before nor the after. The usual responses are to copy
|
|
37
|
+
everything to a backup directory first — which costs time proportional to the
|
|
38
|
+
data, so nobody does it for large trees — or to write bespoke cleanup code per
|
|
39
|
+
script, which is only exercised on the day it is needed and is therefore
|
|
40
|
+
usually wrong.
|
|
41
|
+
|
|
42
|
+
Python has `shutil` for individual operations and `atomicwrites` for making a
|
|
43
|
+
single file write atomic. Neither gives you a *batch* that is all-or-nothing.
|
|
44
|
+
`filetx` is that missing piece: record the operations, commit them, and get the
|
|
45
|
+
tree back unchanged if anything goes wrong — including if the process dies.
|
|
46
|
+
|
|
47
|
+
This started as the transaction log inside a file-organiser tool I built, where
|
|
48
|
+
a half-finished reorganisation of someone's photo library is a genuinely bad
|
|
49
|
+
outcome. The engine turned out to be the interesting part, so it was extracted,
|
|
50
|
+
generalised and published.
|
|
51
|
+
|
|
52
|
+
## 2. What it operates on
|
|
53
|
+
|
|
54
|
+
There is no dataset here — this is a library, and its input is whatever tree the
|
|
55
|
+
caller points it at.
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| Input | Arbitrary files, directories and symlinks on a local filesystem |
|
|
60
|
+
| Scale tested | Trees up to 5,000 files; single renames are size-independent |
|
|
61
|
+
| Benchmark data | Synthetic, generated by `scripts/benchmark.py` (seed 42), not committed |
|
|
62
|
+
| Package licence | MIT |
|
|
63
|
+
| Dependencies | None at runtime. `pytest`, `pytest-cov` and `ruff` for development |
|
|
64
|
+
|
|
65
|
+
**Non-goals.** Not a distributed transaction manager, not crash-safe against
|
|
66
|
+
media failure, and not safe against another process mutating the same paths
|
|
67
|
+
concurrently — see [Limitations](#limitations).
|
|
68
|
+
|
|
69
|
+
## 3. Architecture
|
|
70
|
+
|
|
71
|
+
```mermaid
|
|
72
|
+
flowchart TD
|
|
73
|
+
A["tx.move / copy / delete / mkdir<br/>(recorded, not performed)"] --> B["commit()"]
|
|
74
|
+
B --> C["plan_undo — validate,<br/>compute undo record"]
|
|
75
|
+
C -->|invalid| R
|
|
76
|
+
C --> D["journal — write undo record, fsync"]
|
|
77
|
+
D --> E["apply — change the filesystem"]
|
|
78
|
+
E -->|OSError| R
|
|
79
|
+
E --> F{"more operations?"}
|
|
80
|
+
F -->|yes| C
|
|
81
|
+
F -->|no| G["journal — commit"]
|
|
82
|
+
G --> H["purge staged material"]
|
|
83
|
+
R["revert applied operations,<br/>in reverse order"] --> S{"all reverted?"}
|
|
84
|
+
S -->|yes| T["journal — rollback<br/>raise TransactionError"]
|
|
85
|
+
S -->|no| U["journal left unsettled<br/>raise RollbackError"]
|
|
86
|
+
U -.->|"later: filetx undo"| R
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The undo record is written to the journal **before** the change it describes is
|
|
90
|
+
attempted. That is the whole design: a record written afterwards is useless to a
|
|
91
|
+
process that died in between.
|
|
92
|
+
|
|
93
|
+
Writing it first requires knowing, in advance, where a file is about to be moved
|
|
94
|
+
to — so staged names are derived deterministically from `(path, transaction id,
|
|
95
|
+
plan index)` rather than allocated at random.
|
|
96
|
+
|
|
97
|
+
The remaining ambiguity is that the journal can describe a change that never
|
|
98
|
+
actually happened. Every `revert` therefore inspects the real filesystem before
|
|
99
|
+
acting instead of trusting the record, which makes reverting idempotent: running
|
|
100
|
+
it twice, or against an operation that never applied, does nothing.
|
|
101
|
+
|
|
102
|
+
## 4. Key decisions & tradeoffs
|
|
103
|
+
|
|
104
|
+
| Decision | Chose | Over | Why |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| Making deletes reversible | Rename the target to a hidden sibling in its own parent directory | Copy it to a staging/backup directory | A sibling is guaranteed to be on the same filesystem, so it is one atomic rename regardless of size — **1,280× faster** on a 5,000-file tree (§5). It also nests correctly: deleting `a/b.txt` then `a` stages the file inside `a`, then renames `a` wholesale, and reverse-order rollback restores `a` before looking for `b.txt` inside it. |
|
|
107
|
+
| When to compute the undo record | Before applying (write-ahead) | After applying, returning what was done | The "after" version cannot recover a process killed between the change and the log write — precisely the window that matters. Cost: staged names must be deterministic, and the journal may record changes that never happened, which is why every revert is state-checking. |
|
|
108
|
+
| Operations execute | Deferred — recorded, applied on `commit()` | Eagerly, as each method is called | Makes the plan inspectable and gives a dry run (`tx.describe()`) for free, and an exception in the caller's own code inside the `with` block costs nothing to undo. |
|
|
109
|
+
| Failure signalling | `TransactionError` and `RollbackError` as distinct types | One exception for "the commit failed" | "Your change didn't happen and the tree is fine" and "your change didn't happen and the tree is in an unknown state" demand completely different responses. Collapsing them would be the most dangerous thing this library could do. |
|
|
110
|
+
| Recovery vs. leftover staged data | Report it, never delete it | Clean up automatically | If a revert declined to restore something because the original path is occupied again, the staged copy may be the only copy left. Unattended deletion there is the one unrecoverable mistake available; `filetx undo` prints the paths and stops. |
|
|
111
|
+
| Cross-filesystem moves | Detect `EXDEV`, degrade to copy-then-stage | Refuse, or always copy | Keeps the fast path fast and the slow path correct. Documented as O(size) rather than O(1). Any other `OSError` propagates — a permission error must not be silently treated as a volume boundary. |
|
|
112
|
+
| Journal format | JSON Lines | SQLite, or a binary log | An operator looking at a half-finished batch can read it with `cat`. Appending one short line is atomic enough at this size, and a torn final line is detected and tolerated. |
|
|
113
|
+
|
|
114
|
+
## 5. Results
|
|
115
|
+
|
|
116
|
+
Measured on Windows 11 (NTFS), Python 3.13.9, 12-core CPU / 64 GB RAM, median of
|
|
117
|
+
3 runs. Reproduce with `uv run python scripts/benchmark.py --files 5000 --size 1024`.
|
|
118
|
+
|
|
119
|
+
| Metric | Value | Notes |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| Make a 5,000-file delete reversible, then undo it | **7.6 ms** | Two renames |
|
|
122
|
+
| Same outcome via copy-to-backup then restore | **9,692 ms** | `shutil.copytree` + `rmtree` + `copytree` |
|
|
123
|
+
| Speedup on the reversibility path | **~1,280×** | Widens with tree size; staging is size-independent |
|
|
124
|
+
| Commit the same delete (data actually removed) | 1,407 ms | Staging is O(1), *deleting* is not — stated so the number above is not mistaken for magic |
|
|
125
|
+
| Tests | **73 passing, 100% line coverage** | Includes two tests that `os._exit()` a real subprocess mid-commit, on either side of the rename, then recover from the journal |
|
|
126
|
+
| Python versions | 3.10, 3.11, 3.12, 3.13 | All four run in CI, not just claimed in metadata |
|
|
127
|
+
| Platforms | Linux, Windows, macOS | Windows and macOS on 3.13; this library is mostly `os.rename`, which is where platforms disagree |
|
|
128
|
+
| Runtime dependencies | 0 | |
|
|
129
|
+
|
|
130
|
+
The benchmark tree is synthetic and generated by the script above. The
|
|
131
|
+
comparison is against `shutil`, i.e. what you would write by hand, not against
|
|
132
|
+
another library.
|
|
133
|
+
|
|
134
|
+
## 6. How to run
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git clone https://github.com/Prithv122/filetx.git
|
|
138
|
+
cd filetx
|
|
139
|
+
uv sync
|
|
140
|
+
uv run pytest --cov=src --cov-report=term-missing
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
No environment variables, services or datasets are needed. Other useful commands:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
uv run python scripts/benchmark.py # reproduce the numbers in section 5
|
|
147
|
+
uv run ruff check . && uv run ruff format --check .
|
|
148
|
+
uv build && uv run --with twine twine check --strict dist/*
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Recovering an interrupted run
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
filetx inspect release.jsonl # what was this transaction doing, and how far did it get?
|
|
155
|
+
filetx undo release.jsonl # put it back
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`inspect` marks each operation `done`, `PARTIAL` or `-`. `undo` rolls an
|
|
159
|
+
unsettled transaction back, cleans up after a committed one, and reports — but
|
|
160
|
+
never deletes — staged data whose original path has since been reoccupied.
|
|
161
|
+
|
|
162
|
+
### Limitations
|
|
163
|
+
|
|
164
|
+
- **No locking.** Two transactions touching the same paths concurrently will
|
|
165
|
+
interfere. Serialise them yourself.
|
|
166
|
+
- **`fsync` durability only.** The journal is `fsync`-ed before each change, so
|
|
167
|
+
a process kill or power loss is recoverable, but a lying disk cache or media
|
|
168
|
+
failure is not. Pass `fsync=False` to trade that away for speed.
|
|
169
|
+
- **Staged material is visible.** During a transaction, `.filetx-*` entries
|
|
170
|
+
exist alongside your files. A crash leaves them until `filetx undo` runs;
|
|
171
|
+
`filetx.STAGE_PREFIX` is exported so scanners can skip them.
|
|
172
|
+
- **Cross-filesystem moves are O(size).** They fall back to copying.
|
|
173
|
+
|
|
174
|
+
## 7. What I'd change at 100× scale
|
|
175
|
+
|
|
176
|
+
The design assumes a batch you can hold in memory and a journal you read whole.
|
|
177
|
+
At 100× — hundreds of thousands of operations per transaction — three things
|
|
178
|
+
break, in this order:
|
|
179
|
+
|
|
180
|
+
1. **The plan is written to the journal as one record.** A single JSON line
|
|
181
|
+
holding 500k operations has to be serialised and `fsync`-ed before the first
|
|
182
|
+
change happens, and re-parsed in full during recovery. I would stream the
|
|
183
|
+
plan as one record per operation and make `read_journal` incremental.
|
|
184
|
+
2. **`fsync` per operation dominates.** At ~1 ms per flush, 500k operations is
|
|
185
|
+
over eight minutes of waiting on the disk. I would batch the flush — group
|
|
186
|
+
commits of N records — accepting a bounded window where the journal lags
|
|
187
|
+
reality, and handle it by making recovery re-verify the tail against the
|
|
188
|
+
filesystem, which the state-checking reverts already support.
|
|
189
|
+
3. **Rollback is serial.** Undoing 500k renames one at a time wastes an SSD's
|
|
190
|
+
parallelism. Reverts of operations on disjoint paths could run in a thread
|
|
191
|
+
pool; the ordering constraint is only between operations whose paths nest,
|
|
192
|
+
which is a partial order the planner could compute up front.
|
|
193
|
+
|
|
194
|
+
What I would *not* change is the staging strategy — it is the part that gets
|
|
195
|
+
better with scale, not worse.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## References
|
|
200
|
+
|
|
201
|
+
The write-ahead ordering and the do/undo/purge split follow standard database
|
|
202
|
+
recovery practice; ARIES (Mohan et al., 1992) is the canonical description, and
|
|
203
|
+
the naming of the three phases here is deliberately borrowed from it. No
|
|
204
|
+
implementation was consulted — the filesystem constraints are different enough
|
|
205
|
+
that the resemblance is conceptual.
|
|
206
|
+
|
|
207
|
+
## Licence
|
|
208
|
+
|
|
209
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "filetx"
|
|
3
|
+
dynamic = ["version"] # single-sourced from src/filetx/__init__.py
|
|
4
|
+
description = "Transactional filesystem operations - batch moves, copies and deletes that either all commit or all roll back."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT" # PEP 639 SPDX expression
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Prithvi M", email = "feverishpetroleum16@gmail.com" }]
|
|
10
|
+
keywords = ["filesystem", "transaction", "rollback", "atomic", "undo", "files"]
|
|
11
|
+
|
|
12
|
+
# Zero runtime dependencies. A utility library that pulls in a dependency tree
|
|
13
|
+
# is a library people vendor around instead of installing.
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: System :: Filesystems",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
# No "License ::" classifier - PEP 639 deprecates it when `license` is an SPDX
|
|
29
|
+
# expression, and PyPI rejects uploads that specify both.
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/Prithv122/filetx"
|
|
33
|
+
Source = "https://github.com/Prithv122/filetx"
|
|
34
|
+
Issues = "https://github.com/Prithv122/filetx/issues"
|
|
35
|
+
Changelog = "https://github.com/Prithv122/filetx/blob/main/CHANGELOG.md"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
filetx = "filetx.cli:main"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling>=1.27"] # >=1.27 for PEP 639 license support
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
path = "src/filetx/__init__.py"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/filetx"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["src", "tests", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
dev = [
|
|
55
|
+
"pytest>=8.3",
|
|
56
|
+
"pytest-cov>=6.0",
|
|
57
|
+
"ruff>=0.9",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
pythonpath = ["src"]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
addopts = "-q"
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
src = ["src", "tests"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|