taskfence 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.
- taskfence/__init__.py +11 -0
- taskfence/_hooks.py +32 -0
- taskfence/fence.py +200 -0
- taskfence/outcomes.py +93 -0
- taskfence/stores/__init__.py +3 -0
- taskfence/stores/postgres.py +328 -0
- taskfence/stores/sqlite.py +310 -0
- taskfence/testing/__init__.py +8 -0
- taskfence/testing/audit.py +81 -0
- taskfence/testing/latches.py +138 -0
- taskfence/testing/plugin.py +15 -0
- taskfence/tokens.py +120 -0
- taskfence-0.1.0.dist-info/METADATA +169 -0
- taskfence-0.1.0.dist-info/RECORD +18 -0
- taskfence-0.1.0.dist-info/WHEEL +5 -0
- taskfence-0.1.0.dist-info/entry_points.txt +2 -0
- taskfence-0.1.0.dist-info/licenses/LICENSE +21 -0
- taskfence-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""SQLite store (WAL mode), the construction evaluated in the TaskFence paper.
|
|
2
|
+
|
|
3
|
+
Every TaskFence write transaction increments `taskfence_clock.seq` inside the
|
|
4
|
+
transaction. SQLite serializes writers, so `seq` is the authoritative commit
|
|
5
|
+
order; the auditor relies on it.
|
|
6
|
+
|
|
7
|
+
The guard's first statement is a conditional UPDATE on the grant's fence row.
|
|
8
|
+
It takes the database write lock and checks the fence in one step, so a
|
|
9
|
+
revocation either commits before it (and the check fails) or waits until the
|
|
10
|
+
guarded transaction ends.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Iterator
|
|
20
|
+
|
|
21
|
+
from ..outcomes import Code, Duplicate, Retry, UsageError
|
|
22
|
+
|
|
23
|
+
SCHEMA = """
|
|
24
|
+
CREATE TABLE IF NOT EXISTS taskfence_clock (id INTEGER PRIMARY KEY CHECK (id = 1), seq INTEGER NOT NULL);
|
|
25
|
+
INSERT OR IGNORE INTO taskfence_clock VALUES (1, 0);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS taskfence_fences (
|
|
27
|
+
grant_id TEXT PRIMARY KEY,
|
|
28
|
+
principal TEXT NOT NULL,
|
|
29
|
+
operation TEXT NOT NULL,
|
|
30
|
+
mode TEXT NOT NULL CHECK (mode IN ('embedded', 'federated')),
|
|
31
|
+
epoch INTEGER, -- highest issued epoch; NULL in federated mode
|
|
32
|
+
min_epoch INTEGER NOT NULL, -- tokens below this are dead; only ever increases
|
|
33
|
+
guard_count INTEGER NOT NULL DEFAULT 0
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS taskfence_scope (
|
|
36
|
+
grant_id TEXT NOT NULL, resource TEXT NOT NULL, PRIMARY KEY (grant_id, resource));
|
|
37
|
+
CREATE TABLE IF NOT EXISTS taskfence_fence_log (
|
|
38
|
+
seq INTEGER PRIMARY KEY, grant_id TEXT NOT NULL, kind TEXT NOT NULL,
|
|
39
|
+
epoch INTEGER, min_epoch INTEGER NOT NULL);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS taskfence_ops (
|
|
41
|
+
operation_id TEXT PRIMARY KEY,
|
|
42
|
+
state TEXT NOT NULL CHECK (state IN ('COMMITTED', 'CANCELLED')),
|
|
43
|
+
task_id TEXT NOT NULL,
|
|
44
|
+
grant_id TEXT,
|
|
45
|
+
epoch INTEGER,
|
|
46
|
+
resource TEXT,
|
|
47
|
+
seq INTEGER NOT NULL UNIQUE,
|
|
48
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS taskfence_cancelled_tasks (task_id TEXT PRIMARY KEY, seq INTEGER NOT NULL);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS taskfence_receipts (
|
|
51
|
+
msg_id TEXT PRIMARY KEY, kind TEXT NOT NULL, grant_id TEXT NOT NULL, epoch INTEGER NOT NULL,
|
|
52
|
+
applied INTEGER NOT NULL, min_epoch INTEGER NOT NULL, seq INTEGER NOT NULL);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS taskfence_outbox (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
operation_id TEXT NOT NULL REFERENCES taskfence_ops(operation_id),
|
|
56
|
+
topic TEXT NOT NULL, payload TEXT NOT NULL, delivered_at TEXT);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def connect(path: str | Path, *, timeout_s: float = 10.0) -> sqlite3.Connection:
|
|
61
|
+
"""Open a connection configured the way TaskFence expects.
|
|
62
|
+
|
|
63
|
+
WAL mode, explicit transactions (`isolation_level=None`), row access by name.
|
|
64
|
+
Use one connection per thread.
|
|
65
|
+
"""
|
|
66
|
+
c = sqlite3.connect(str(path), timeout=timeout_s, isolation_level=None, check_same_thread=False)
|
|
67
|
+
c.row_factory = sqlite3.Row
|
|
68
|
+
c.execute("PRAGMA journal_mode=WAL")
|
|
69
|
+
c.execute("PRAGMA foreign_keys=ON")
|
|
70
|
+
return c
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _transient(e: sqlite3.OperationalError) -> bool:
|
|
74
|
+
m = str(e).lower()
|
|
75
|
+
return "locked" in m or "busy" in m
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def translate_errors() -> Iterator[None]:
|
|
80
|
+
try:
|
|
81
|
+
yield
|
|
82
|
+
except sqlite3.OperationalError as e:
|
|
83
|
+
if _transient(e):
|
|
84
|
+
raise Retry(str(e)) from e
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SQLiteStore:
|
|
89
|
+
name = "sqlite"
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def errors():
|
|
93
|
+
return translate_errors()
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------- transactions
|
|
96
|
+
|
|
97
|
+
def migrate(self, conn: sqlite3.Connection) -> None:
|
|
98
|
+
if conn.in_transaction:
|
|
99
|
+
raise UsageError("migrate() must not run inside an open transaction")
|
|
100
|
+
conn.executescript(SCHEMA)
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def transaction(self, conn: sqlite3.Connection) -> Iterator[None]:
|
|
104
|
+
"""BEGIN IMMEDIATE ... COMMIT, rolling back on any exception."""
|
|
105
|
+
if getattr(conn, "autocommit", None) is False: # Python 3.12+ PEP 249 mode
|
|
106
|
+
try:
|
|
107
|
+
yield
|
|
108
|
+
conn.commit()
|
|
109
|
+
except BaseException:
|
|
110
|
+
conn.rollback()
|
|
111
|
+
raise
|
|
112
|
+
return
|
|
113
|
+
if conn.in_transaction:
|
|
114
|
+
raise UsageError("a transaction is already open on this connection")
|
|
115
|
+
with translate_errors():
|
|
116
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
with translate_errors():
|
|
120
|
+
conn.execute("COMMIT")
|
|
121
|
+
except BaseException:
|
|
122
|
+
if conn.in_transaction:
|
|
123
|
+
conn.execute("ROLLBACK")
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
@contextmanager
|
|
127
|
+
def owned_or_joined(self, conn: sqlite3.Connection) -> Iterator[bool]:
|
|
128
|
+
"""Join the caller's transaction if one is open; otherwise own one. Yields True if owned."""
|
|
129
|
+
if conn.in_transaction and getattr(conn, "autocommit", None) is not False:
|
|
130
|
+
yield False
|
|
131
|
+
else:
|
|
132
|
+
with self.transaction(conn):
|
|
133
|
+
yield True
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def require_transaction(conn: sqlite3.Connection) -> None:
|
|
137
|
+
if not conn.in_transaction:
|
|
138
|
+
raise UsageError("guard() must run inside the transaction that commits the effect; "
|
|
139
|
+
"use fence.transaction(conn)")
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def bump(conn: sqlite3.Connection) -> int:
|
|
143
|
+
return conn.execute("UPDATE taskfence_clock SET seq = seq + 1 WHERE id = 1 RETURNING seq").fetchone()[0]
|
|
144
|
+
|
|
145
|
+
def _log(self, conn: sqlite3.Connection, grant_id: str, kind: str, epoch: int | None, min_epoch: int) -> int:
|
|
146
|
+
seq = self.bump(conn)
|
|
147
|
+
conn.execute("INSERT INTO taskfence_fence_log VALUES (?, ?, ?, ?, ?)", (seq, grant_id, kind, epoch, min_epoch))
|
|
148
|
+
return seq
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------- reads
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def fence_row(conn: sqlite3.Connection, grant_id: str) -> dict[str, Any] | None:
|
|
154
|
+
r = conn.execute("SELECT grant_id, principal, operation, mode, epoch, min_epoch FROM taskfence_fences"
|
|
155
|
+
" WHERE grant_id = ?", (grant_id,)).fetchone()
|
|
156
|
+
return None if r is None else dict(zip(("grant_id", "principal", "operation", "mode", "epoch",
|
|
157
|
+
"min_epoch"), tuple(r)))
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def scope(conn: sqlite3.Connection, grant_id: str) -> tuple[str, ...]:
|
|
161
|
+
rows = conn.execute("SELECT resource FROM taskfence_scope WHERE grant_id = ? ORDER BY resource",
|
|
162
|
+
(grant_id,)).fetchall()
|
|
163
|
+
return tuple(r[0] for r in rows)
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------- embedded admin
|
|
166
|
+
|
|
167
|
+
def create_grant(self, conn, grant_id: str, principal: str, operation: str, resources: list[str]) -> int:
|
|
168
|
+
conn.execute("INSERT INTO taskfence_fences (grant_id, principal, operation, mode, epoch, min_epoch)"
|
|
169
|
+
" VALUES (?, ?, ?, 'embedded', 1, 1)", (grant_id, principal, operation))
|
|
170
|
+
conn.executemany("INSERT INTO taskfence_scope VALUES (?, ?)", [(grant_id, r) for r in resources])
|
|
171
|
+
return self._log(conn, grant_id, "GRANT", 1, 1)
|
|
172
|
+
|
|
173
|
+
def revoke(self, conn, grant_id: str) -> tuple[int, int]:
|
|
174
|
+
e, floor = conn.execute("UPDATE taskfence_fences SET min_epoch = max(min_epoch, epoch + 1)"
|
|
175
|
+
" WHERE grant_id = ? RETURNING epoch, min_epoch", (grant_id,)).fetchone()
|
|
176
|
+
return floor, self._log(conn, grant_id, "REVOKE", e, floor)
|
|
177
|
+
|
|
178
|
+
def regrant(self, conn, grant_id: str, resources: list[str] | None) -> tuple[int, int]:
|
|
179
|
+
new_epoch = conn.execute("UPDATE taskfence_fences SET epoch = max(epoch, min_epoch - 1) + 1,"
|
|
180
|
+
" min_epoch = max(epoch, min_epoch - 1) + 1 WHERE grant_id = ? RETURNING epoch",
|
|
181
|
+
(grant_id,)).fetchone()[0]
|
|
182
|
+
if resources is not None:
|
|
183
|
+
self._replace_scope(conn, grant_id, resources)
|
|
184
|
+
return new_epoch, self._log(conn, grant_id, "REGRANT", new_epoch, new_epoch)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _replace_scope(conn, grant_id: str, resources: list[str]) -> None:
|
|
188
|
+
conn.execute("DELETE FROM taskfence_scope WHERE grant_id = ?", (grant_id,))
|
|
189
|
+
conn.executemany("INSERT INTO taskfence_scope VALUES (?, ?)", [(grant_id, r) for r in resources])
|
|
190
|
+
|
|
191
|
+
def cancel_task(self, conn, task_id: str) -> int:
|
|
192
|
+
seq = self.bump(conn)
|
|
193
|
+
conn.execute("INSERT OR IGNORE INTO taskfence_cancelled_tasks VALUES (?, ?)", (task_id, seq))
|
|
194
|
+
return seq
|
|
195
|
+
|
|
196
|
+
def cancel_operation(self, conn, operation_id: str, task_id: str) -> bool:
|
|
197
|
+
"""Tombstone an operation that has not committed. Returns False if it already committed."""
|
|
198
|
+
prior = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = ?", (operation_id,)).fetchone()
|
|
199
|
+
if prior is not None:
|
|
200
|
+
return prior[0] == "CANCELLED"
|
|
201
|
+
seq = self.bump(conn)
|
|
202
|
+
conn.execute("INSERT INTO taskfence_ops (operation_id, state, task_id, seq) VALUES (?, 'CANCELLED', ?, ?)",
|
|
203
|
+
(operation_id, task_id, seq))
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------- guard steps
|
|
207
|
+
|
|
208
|
+
def lock_and_check_fence(self, conn, grant_id: str, principal: str, operation: str, epoch: int) -> Code | None:
|
|
209
|
+
"""Take the write lock via the fence row and check the epoch. None means the fence admits the token."""
|
|
210
|
+
cur = conn.execute(
|
|
211
|
+
"UPDATE taskfence_fences SET guard_count = guard_count + 1"
|
|
212
|
+
" WHERE grant_id = :g AND principal = :p AND operation = :op"
|
|
213
|
+
" AND :e >= min_epoch AND (epoch IS NULL OR :e <= epoch)",
|
|
214
|
+
{"g": grant_id, "p": principal, "op": operation, "e": epoch})
|
|
215
|
+
if cur.rowcount == 1:
|
|
216
|
+
# Federated mode: an authentic newer epoch proves a regrant, so raise the floor (D5 semantics).
|
|
217
|
+
adv = conn.execute("UPDATE taskfence_fences SET min_epoch = ? WHERE grant_id = ? AND mode = 'federated'"
|
|
218
|
+
" AND min_epoch < ?", (epoch, grant_id, epoch))
|
|
219
|
+
if adv.rowcount == 1:
|
|
220
|
+
self._log(conn, grant_id, "ADVANCE", epoch, epoch)
|
|
221
|
+
return None
|
|
222
|
+
return self._diagnose(conn, grant_id, principal, operation, epoch)
|
|
223
|
+
|
|
224
|
+
def _diagnose(self, conn, grant_id: str, principal: str, operation: str, epoch: int) -> Code:
|
|
225
|
+
f = self.fence_row(conn, grant_id)
|
|
226
|
+
if f is None:
|
|
227
|
+
return Code.NO_GRANT
|
|
228
|
+
if principal != f["principal"]:
|
|
229
|
+
return Code.PRINCIPAL_MISMATCH
|
|
230
|
+
if operation != f["operation"]:
|
|
231
|
+
return Code.OPERATION_MISMATCH
|
|
232
|
+
if epoch < f["min_epoch"]:
|
|
233
|
+
return Code.STALE_EPOCH
|
|
234
|
+
return Code.FUTURE_EPOCH
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def in_scope(conn, grant_id: str, resource: str | None) -> bool:
|
|
238
|
+
return conn.execute(
|
|
239
|
+
"SELECT 1 FROM taskfence_scope WHERE grant_id = ? AND (resource = '*' OR resource = ?)",
|
|
240
|
+
(grant_id, resource)).fetchone() is not None
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def task_cancelled(conn, task_id: str) -> bool:
|
|
244
|
+
return conn.execute("SELECT 1 FROM taskfence_cancelled_tasks WHERE task_id = ?",
|
|
245
|
+
(task_id,)).fetchone() is not None
|
|
246
|
+
|
|
247
|
+
def insert_op(self, conn, operation_id: str, task_id: str, grant_id: str, epoch: int,
|
|
248
|
+
resource: str | None) -> int | Code:
|
|
249
|
+
"""Returns the commit seq, or Code.OPERATION_CANCELLED. Raises Duplicate."""
|
|
250
|
+
prior = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = ?", (operation_id,)).fetchone()
|
|
251
|
+
if prior is not None:
|
|
252
|
+
if prior[0] == "CANCELLED":
|
|
253
|
+
return Code.OPERATION_CANCELLED
|
|
254
|
+
raise Duplicate(operation_id)
|
|
255
|
+
seq = self.bump(conn)
|
|
256
|
+
try:
|
|
257
|
+
conn.execute("INSERT INTO taskfence_ops (operation_id, state, task_id, grant_id, epoch, resource, seq)"
|
|
258
|
+
" VALUES (?, 'COMMITTED', ?, ?, ?, ?, ?)",
|
|
259
|
+
(operation_id, task_id, grant_id, epoch, resource, seq))
|
|
260
|
+
except sqlite3.IntegrityError:
|
|
261
|
+
raise Duplicate(operation_id) from None
|
|
262
|
+
return seq
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------- federated owner side
|
|
265
|
+
|
|
266
|
+
def apply_message(self, conn, msg: dict[str, Any]) -> dict[str, Any]:
|
|
267
|
+
"""Apply INSTALL or FENCE idempotently (by msg_id). The floor is max(floor, epoch)."""
|
|
268
|
+
prior = conn.execute("SELECT * FROM taskfence_receipts WHERE msg_id = ?", (msg["msg_id"],)).fetchone()
|
|
269
|
+
if prior is not None:
|
|
270
|
+
return {**dict(zip(("msg_id", "kind", "grant_id", "epoch", "applied", "min_epoch", "seq"), tuple(prior))),
|
|
271
|
+
"duplicate": True}
|
|
272
|
+
kind, g = msg["kind"], msg["grant_id"]
|
|
273
|
+
e = int(msg["epoch"] if kind == "INSTALL" else msg["fence_epoch"])
|
|
274
|
+
cur = self.fence_row(conn, g)
|
|
275
|
+
if cur is not None and cur["mode"] != "federated":
|
|
276
|
+
raise UsageError(f"grant {g!r} is an embedded grant; federated messages cannot change it")
|
|
277
|
+
applied = cur is None or e > cur["min_epoch"]
|
|
278
|
+
if cur is None:
|
|
279
|
+
conn.execute("INSERT INTO taskfence_fences (grant_id, principal, operation, mode, epoch, min_epoch)"
|
|
280
|
+
" VALUES (?, ?, ?, 'federated', NULL, ?)", (g, msg["principal"], msg["operation"], e))
|
|
281
|
+
elif applied:
|
|
282
|
+
conn.execute("UPDATE taskfence_fences SET min_epoch = ? WHERE grant_id = ?", (e, g))
|
|
283
|
+
if kind == "INSTALL" and applied:
|
|
284
|
+
self._replace_scope(conn, g, list(msg.get("resources", [])))
|
|
285
|
+
floor = self.fence_row(conn, g)["min_epoch"]
|
|
286
|
+
seq = self._log(conn, g, kind if applied else f"{kind}_NOOP", e, floor)
|
|
287
|
+
conn.execute("INSERT INTO taskfence_receipts VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
288
|
+
(msg["msg_id"], kind, g, e, int(applied), floor, seq))
|
|
289
|
+
return {"msg_id": msg["msg_id"], "kind": kind, "grant_id": g, "epoch": e, "applied": int(applied),
|
|
290
|
+
"min_epoch": floor, "seq": seq, "duplicate": False}
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------- outbox
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def outbox_enqueue(conn, operation_id: str, topic: str, payload: Any) -> int:
|
|
296
|
+
r = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = ?", (operation_id,)).fetchone()
|
|
297
|
+
if r is None or r[0] != "COMMITTED":
|
|
298
|
+
raise UsageError("enqueue only after guard() admitted this operation_id, in the same transaction")
|
|
299
|
+
return conn.execute("INSERT INTO taskfence_outbox (operation_id, topic, payload) VALUES (?, ?, ?)",
|
|
300
|
+
(operation_id, topic, json.dumps(payload))).lastrowid
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def outbox_pending(conn, limit: int) -> list[dict[str, Any]]:
|
|
304
|
+
rows = conn.execute("SELECT id, operation_id, topic, payload FROM taskfence_outbox"
|
|
305
|
+
" WHERE delivered_at IS NULL ORDER BY id LIMIT ?", (limit,)).fetchall()
|
|
306
|
+
return [{"id": r[0], "operation_id": r[1], "topic": r[2], "payload": json.loads(r[3])} for r in rows]
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def outbox_mark_delivered(conn, entry_id: int) -> None:
|
|
310
|
+
conn.execute("UPDATE taskfence_outbox SET delivered_at = CURRENT_TIMESTAMP WHERE id = ?", (entry_id,))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Test helpers: latches for deterministic races and an independent auditor."""
|
|
2
|
+
|
|
3
|
+
from .._hooks import GUARD_POINTS, hook
|
|
4
|
+
from .audit import assert_no_violations, audit
|
|
5
|
+
from .latches import Latches, Pause, ScheduleTimeout, Worker, latches, run_in_thread
|
|
6
|
+
|
|
7
|
+
__all__ = ["GUARD_POINTS", "hook", "audit", "assert_no_violations", "Latches", "Pause", "ScheduleTimeout",
|
|
8
|
+
"Worker", "latches", "run_in_thread"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Independent auditor: judges the final database with SQL, not with guard code.
|
|
2
|
+
|
|
3
|
+
A violation is a committed operation whose commit position (`seq`) comes after
|
|
4
|
+
a fence-log entry that had already raised the grant's floor above the
|
|
5
|
+
operation's epoch, or after its task was cancelled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
POST_REVOCATION = """
|
|
13
|
+
SELECT o.operation_id, o.task_id, o.grant_id, o.epoch, o.seq AS commit_seq,
|
|
14
|
+
MIN(l.seq) AS fence_seq, 'POST_REVOCATION' AS kind
|
|
15
|
+
FROM taskfence_ops o
|
|
16
|
+
JOIN taskfence_fence_log l
|
|
17
|
+
ON l.grant_id = o.grant_id AND l.seq < o.seq AND l.min_epoch > o.epoch
|
|
18
|
+
WHERE o.state = 'COMMITTED'
|
|
19
|
+
GROUP BY o.operation_id
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
POST_CANCELLATION = """
|
|
23
|
+
SELECT o.operation_id, o.task_id, o.grant_id, o.epoch, o.seq AS commit_seq,
|
|
24
|
+
c.seq AS fence_seq, 'POST_CANCELLATION' AS kind
|
|
25
|
+
FROM taskfence_ops o
|
|
26
|
+
JOIN taskfence_cancelled_tasks c ON c.task_id = o.task_id AND c.seq < o.seq
|
|
27
|
+
WHERE o.state = 'COMMITTED'
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# PostgreSQL: `seq` comes from a sequence and is not commit order, so compare the
|
|
32
|
+
# commit timestamps of the rows' inserting transactions instead.
|
|
33
|
+
PG_POST_REVOCATION = """
|
|
34
|
+
SELECT o.operation_id, o.task_id, o.grant_id, o.epoch, o.seq AS commit_seq,
|
|
35
|
+
MIN(l.seq) AS fence_seq, 'POST_REVOCATION' AS kind
|
|
36
|
+
FROM taskfence_ops o
|
|
37
|
+
JOIN taskfence_fence_log l
|
|
38
|
+
ON l.grant_id = o.grant_id AND l.min_epoch > o.epoch
|
|
39
|
+
AND pg_xact_commit_timestamp(l.xmin) < pg_xact_commit_timestamp(o.xmin)
|
|
40
|
+
WHERE o.state = 'COMMITTED'
|
|
41
|
+
GROUP BY o.operation_id, o.task_id, o.grant_id, o.epoch, o.seq
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
PG_POST_CANCELLATION = """
|
|
45
|
+
SELECT o.operation_id, o.task_id, o.grant_id, o.epoch, o.seq AS commit_seq,
|
|
46
|
+
MIN(c.seq) AS fence_seq, 'POST_CANCELLATION' AS kind
|
|
47
|
+
FROM taskfence_ops o
|
|
48
|
+
JOIN taskfence_cancel_log c
|
|
49
|
+
ON c.task_id = o.task_id AND pg_xact_commit_timestamp(c.xmin) < pg_xact_commit_timestamp(o.xmin)
|
|
50
|
+
WHERE o.state = 'COMMITTED'
|
|
51
|
+
GROUP BY o.operation_id, o.task_id, o.grant_id, o.epoch, o.seq
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_postgres(conn) -> bool:
|
|
56
|
+
return type(conn).__module__.startswith("psycopg")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def audit(conn) -> list[dict[str, Any]]:
|
|
60
|
+
"""Return every violation of the safety property found in the database (empty list = clean).
|
|
61
|
+
|
|
62
|
+
On PostgreSQL this needs `track_commit_timestamp = on`.
|
|
63
|
+
"""
|
|
64
|
+
cols = ("operation_id", "task_id", "grant_id", "epoch", "commit_seq", "fence_seq", "kind")
|
|
65
|
+
queries = (POST_REVOCATION, POST_CANCELLATION)
|
|
66
|
+
if _is_postgres(conn):
|
|
67
|
+
if conn.execute("SHOW track_commit_timestamp").fetchone()[0] != "on":
|
|
68
|
+
raise RuntimeError("the PostgreSQL auditor needs track_commit_timestamp = on (server setting)")
|
|
69
|
+
queries = (PG_POST_REVOCATION, PG_POST_CANCELLATION)
|
|
70
|
+
out: list[dict[str, Any]] = []
|
|
71
|
+
for q in queries:
|
|
72
|
+
out.extend(dict(zip(cols, tuple(r))) for r in conn.execute(q).fetchall())
|
|
73
|
+
return sorted(out, key=lambda v: v["commit_seq"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def assert_no_violations(conn) -> None:
|
|
77
|
+
v = audit(conn)
|
|
78
|
+
if v:
|
|
79
|
+
lines = "\n".join(f" {x['kind']}: op={x['operation_id']} epoch={x['epoch']} "
|
|
80
|
+
f"committed at seq {x['commit_seq']} after fence seq {x['fence_seq']}" for x in v)
|
|
81
|
+
raise AssertionError(f"{len(v)} TaskFence safety violation(s):\n{lines}")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Deterministic race scheduling with latches, not sleeps.
|
|
2
|
+
|
|
3
|
+
A worker that reaches a paused hook point blocks until the test releases it.
|
|
4
|
+
Timeouts only turn a hung schedule into a test failure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from typing import Any, Callable, Iterator
|
|
12
|
+
|
|
13
|
+
from .. import _hooks
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScheduleTimeout(AssertionError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Pause:
|
|
23
|
+
def __init__(self, point: str, match: Callable[[dict[str, Any]], bool] | None):
|
|
24
|
+
self.point = point
|
|
25
|
+
self.match = match
|
|
26
|
+
self.ctx: dict[str, Any] = {}
|
|
27
|
+
self._reached = threading.Event()
|
|
28
|
+
self._go = threading.Event()
|
|
29
|
+
self._consumed = False
|
|
30
|
+
|
|
31
|
+
def wait_reached(self, timeout: float = DEFAULT_TIMEOUT_S) -> dict[str, Any]:
|
|
32
|
+
if not self._reached.wait(timeout):
|
|
33
|
+
raise ScheduleTimeout(f"no worker reached {self.point!r} within {timeout}s")
|
|
34
|
+
return self.ctx
|
|
35
|
+
|
|
36
|
+
def release(self) -> None:
|
|
37
|
+
self._go.set()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def reached(self) -> bool:
|
|
41
|
+
return self._reached.is_set()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Latches:
|
|
45
|
+
"""Install with `with Latches() as l:`; arrange pauses or faults at hook points.
|
|
46
|
+
|
|
47
|
+
Points inside the library are in `taskfence._hooks.GUARD_POINTS`. Application
|
|
48
|
+
code may add its own with `taskfence.testing.hook("app.whatever")`.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, timeout_s: float = DEFAULT_TIMEOUT_S):
|
|
52
|
+
self.timeout_s = timeout_s
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._pauses: list[Pause] = []
|
|
55
|
+
self._faults: dict[str, list[tuple[BaseException, Callable[[dict[str, Any]], bool] | None]]] = {}
|
|
56
|
+
self.trace: list[tuple[str, dict[str, Any]]] = []
|
|
57
|
+
|
|
58
|
+
def pause_at(self, point: str, *, match: Callable[[dict[str, Any]], bool] | None = None) -> Pause:
|
|
59
|
+
"""Pause the first worker that reaches `point` (and satisfies `match`, if given)."""
|
|
60
|
+
p = Pause(point, match)
|
|
61
|
+
with self._lock:
|
|
62
|
+
self._pauses.append(p)
|
|
63
|
+
return p
|
|
64
|
+
|
|
65
|
+
def fail_at(self, point: str, exc: BaseException, *,
|
|
66
|
+
match: Callable[[dict[str, Any]], bool] | None = None) -> None:
|
|
67
|
+
"""Raise `exc` in the first worker that reaches `point`."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._faults.setdefault(point, []).append((exc, match))
|
|
70
|
+
|
|
71
|
+
def arrive(self, point: str, ctx: dict[str, Any]) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self.trace.append((point, dict(ctx)))
|
|
74
|
+
pause = next((p for p in self._pauses if p.point == point and not p._consumed
|
|
75
|
+
and (p.match is None or p.match(ctx))), None)
|
|
76
|
+
if pause is not None:
|
|
77
|
+
pause._consumed = True
|
|
78
|
+
faults = self._faults.get(point, [])
|
|
79
|
+
fault = next(((e, m) for e, m in faults if m is None or m(ctx)), None)
|
|
80
|
+
if fault is not None:
|
|
81
|
+
faults.remove(fault)
|
|
82
|
+
if fault is not None:
|
|
83
|
+
raise fault[0]
|
|
84
|
+
if pause is not None:
|
|
85
|
+
pause.ctx = dict(ctx)
|
|
86
|
+
pause._reached.set()
|
|
87
|
+
if not pause._go.wait(self.timeout_s):
|
|
88
|
+
raise ScheduleTimeout(f"{point!r} was never released")
|
|
89
|
+
|
|
90
|
+
def release_all(self) -> None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
for p in self._pauses:
|
|
93
|
+
p.release()
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> "Latches":
|
|
96
|
+
_hooks.install(self)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *exc: object) -> None:
|
|
100
|
+
self.release_all()
|
|
101
|
+
_hooks.install(None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextmanager
|
|
105
|
+
def latches(timeout_s: float = DEFAULT_TIMEOUT_S) -> Iterator[Latches]:
|
|
106
|
+
with Latches(timeout_s) as l:
|
|
107
|
+
yield l
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_in_thread(fn: Callable[[], Any]) -> "Worker":
|
|
111
|
+
w = Worker(fn)
|
|
112
|
+
w.start()
|
|
113
|
+
return w
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Worker(threading.Thread):
|
|
117
|
+
"""Thread that records its return value or exception for the test to inspect."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, fn: Callable[[], Any]):
|
|
120
|
+
super().__init__(daemon=True)
|
|
121
|
+
self.fn = fn
|
|
122
|
+
self.result: Any = None
|
|
123
|
+
self.error: BaseException | None = None
|
|
124
|
+
|
|
125
|
+
def run(self) -> None:
|
|
126
|
+
try:
|
|
127
|
+
self.result = self.fn()
|
|
128
|
+
except BaseException as e: # noqa: BLE001 - surfaced to the test
|
|
129
|
+
self.error = e
|
|
130
|
+
|
|
131
|
+
def outcome(self, timeout: float = DEFAULT_TIMEOUT_S) -> Any:
|
|
132
|
+
"""Join, then return the result or re-raise the worker's exception."""
|
|
133
|
+
self.join(timeout)
|
|
134
|
+
if self.is_alive():
|
|
135
|
+
raise ScheduleTimeout("worker did not finish")
|
|
136
|
+
if self.error is not None:
|
|
137
|
+
raise self.error
|
|
138
|
+
return self.result
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""pytest plugin: `taskfence_latches` fixture (registered via the pytest11 entry point)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterator
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from .latches import Latches
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def taskfence_latches() -> Iterator[Latches]:
|
|
14
|
+
with Latches() as l:
|
|
15
|
+
yield l
|
taskfence/tokens.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Signed task tokens: `tf1.<base64url(claims)>.<base64url(hmac-sha256)>`.
|
|
2
|
+
|
|
3
|
+
Standard library only. A `KeyRing` holds several keys by `kid`: one signs, all
|
|
4
|
+
verify, so keys rotate without invalidating tokens in flight. Removing a kid
|
|
5
|
+
makes its tokens fail with UNKNOWN_KEY.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from .outcomes import Code, Denied
|
|
20
|
+
|
|
21
|
+
PREFIX = "tf1"
|
|
22
|
+
MIN_KEY_BYTES = 32
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _b64e(b: bytes) -> str:
|
|
26
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _b64d(s: str) -> bytes:
|
|
30
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Claims:
|
|
35
|
+
grant_id: str
|
|
36
|
+
principal: str
|
|
37
|
+
operation: str
|
|
38
|
+
epoch: int
|
|
39
|
+
task_id: str
|
|
40
|
+
kid: str
|
|
41
|
+
iat: int
|
|
42
|
+
exp: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KeyRing:
|
|
46
|
+
def __init__(self, keys: dict[str, bytes], signing_kid: str):
|
|
47
|
+
if signing_kid not in keys:
|
|
48
|
+
raise ValueError("signing_kid must be one of the keys")
|
|
49
|
+
for kid, k in keys.items():
|
|
50
|
+
if len(k) < MIN_KEY_BYTES:
|
|
51
|
+
raise ValueError(f"key {kid!r} is shorter than {MIN_KEY_BYTES} bytes")
|
|
52
|
+
self._keys = dict(keys)
|
|
53
|
+
self.signing_kid = signing_kid
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str: # never print key material
|
|
56
|
+
return f"KeyRing(kids={sorted(self._keys)}, signing_kid={self.signing_kid!r})"
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def generate(cls, kid: str = "k1") -> "KeyRing":
|
|
60
|
+
return cls({kid: os.urandom(32)}, kid)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_string(cls, spec: str) -> "KeyRing":
|
|
64
|
+
"""`kid:base64key[,kid:base64key...]`; the first entry signs."""
|
|
65
|
+
keys: dict[str, bytes] = {}
|
|
66
|
+
for part in filter(None, (p.strip() for p in spec.split(","))):
|
|
67
|
+
kid, _, b64 = part.partition(":")
|
|
68
|
+
if not kid or not b64:
|
|
69
|
+
raise ValueError("expected kid:base64key")
|
|
70
|
+
keys[kid] = _b64d(b64)
|
|
71
|
+
if not keys:
|
|
72
|
+
raise ValueError("no keys")
|
|
73
|
+
return cls(keys, next(iter(keys)))
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_env(cls, var: str = "TASKFENCE_KEYS") -> "KeyRing":
|
|
77
|
+
spec = os.environ.get(var)
|
|
78
|
+
if not spec:
|
|
79
|
+
raise ValueError(f"environment variable {var} is not set")
|
|
80
|
+
return cls.from_string(spec)
|
|
81
|
+
|
|
82
|
+
def key(self, kid: str) -> bytes | None:
|
|
83
|
+
return self._keys.get(kid)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sign(claims: Claims, keys: KeyRing) -> str:
|
|
87
|
+
key = keys.key(claims.kid)
|
|
88
|
+
if key is None:
|
|
89
|
+
raise ValueError(f"unknown kid {claims.kid!r}")
|
|
90
|
+
body = _b64e(json.dumps(asdict(claims), separators=(",", ":"), sort_keys=True).encode())
|
|
91
|
+
mac = hmac.new(key, f"{PREFIX}.{body}".encode(), hashlib.sha256).digest()
|
|
92
|
+
return f"{PREFIX}.{body}.{_b64e(mac)}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def verify(token: str, keys: KeyRing, *, now: Callable[[], float] = time.time,
|
|
96
|
+
leeway_s: int = 30) -> Claims:
|
|
97
|
+
"""Return the claims or raise `Denied`. Checks format, key, MAC and expiry."""
|
|
98
|
+
try:
|
|
99
|
+
prefix, body, mac = token.split(".")
|
|
100
|
+
if prefix != PREFIX:
|
|
101
|
+
raise ValueError
|
|
102
|
+
raw = json.loads(_b64d(body))
|
|
103
|
+
claims = Claims(**raw)
|
|
104
|
+
if not isinstance(claims.epoch, int) or not isinstance(claims.exp, int):
|
|
105
|
+
raise ValueError
|
|
106
|
+
except (ValueError, TypeError, AttributeError):
|
|
107
|
+
raise Denied(Code.BAD_TOKEN) from None
|
|
108
|
+
key = keys.key(claims.kid)
|
|
109
|
+
if key is None:
|
|
110
|
+
raise Denied(Code.UNKNOWN_KEY, claims.kid)
|
|
111
|
+
expected = hmac.new(key, f"{PREFIX}.{body}".encode(), hashlib.sha256).digest()
|
|
112
|
+
try:
|
|
113
|
+
given = _b64d(mac)
|
|
114
|
+
except ValueError:
|
|
115
|
+
raise Denied(Code.BAD_TOKEN) from None
|
|
116
|
+
if not hmac.compare_digest(expected, given):
|
|
117
|
+
raise Denied(Code.BAD_SIGNATURE)
|
|
118
|
+
if now() > claims.exp + leeway_s:
|
|
119
|
+
raise Denied(Code.EXPIRED)
|
|
120
|
+
return claims
|