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
taskfence/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""TaskFence: revocation-safe side effects for long-running AI agent tasks."""
|
|
2
|
+
|
|
3
|
+
from .fence import Fence
|
|
4
|
+
from .outcomes import (Admission, Code, Denied, Duplicate, Grant, Receipt, Retry, Revocation, TaskFenceError,
|
|
5
|
+
UsageError)
|
|
6
|
+
from .tokens import Claims, KeyRing
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["Fence", "KeyRing", "Claims", "Admission", "Code", "Denied", "Duplicate", "Grant", "Receipt", "Retry",
|
|
11
|
+
"Revocation", "TaskFenceError", "UsageError"]
|
taskfence/_hooks.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Named hook points inside the library. No-ops unless a test installs a controller.
|
|
2
|
+
|
|
3
|
+
Kept separate from `taskfence.testing` so the core never imports test code.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
GUARD_POINTS = (
|
|
11
|
+
"guard.before_fence", # caller's transaction open, fence row not yet locked
|
|
12
|
+
"guard.after_fence", # fence row locked, every check passed, op row not yet inserted
|
|
13
|
+
"guard.after_insert", # op row inserted; guard() is about to return
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Controller(Protocol):
|
|
18
|
+
def arrive(self, point: str, ctx: dict[str, Any]) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_controller: Controller | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install(c: Controller | None) -> None:
|
|
25
|
+
global _controller
|
|
26
|
+
_controller = c
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hook(point: str, **ctx: Any) -> None:
|
|
30
|
+
c = _controller
|
|
31
|
+
if c is not None:
|
|
32
|
+
c.arrive(point, ctx)
|
taskfence/fence.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The `Fence` facade: grants, revocation, tokens and the commit-time guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import AbstractContextManager
|
|
9
|
+
from typing import Any, Callable, TypeVar
|
|
10
|
+
|
|
11
|
+
from . import _hooks
|
|
12
|
+
from .outcomes import Admission, Code, Denied, Grant, Receipt, Revocation, UsageError
|
|
13
|
+
from .stores.sqlite import SQLiteStore
|
|
14
|
+
from .tokens import Claims, KeyRing, sign, verify
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
Predicate = Callable[[Any, Claims, "str | None"], bool]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Fence:
|
|
21
|
+
"""Commit-time authorization for long-running agent tasks.
|
|
22
|
+
|
|
23
|
+
The safety property: once a revocation is effective, no effect admitted under
|
|
24
|
+
a revoked epoch commits. In embedded mode a revocation is effective when
|
|
25
|
+
`revoke()` returns (or when the caller's transaction that ran it commits).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, keys: KeyRing, store: SQLiteStore | None = None, *,
|
|
29
|
+
clock: Callable[[], float] = time.time, leeway_s: int = 30):
|
|
30
|
+
self.keys = keys
|
|
31
|
+
self.store = store or SQLiteStore()
|
|
32
|
+
self.clock = clock
|
|
33
|
+
self.leeway_s = leeway_s
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ setup
|
|
36
|
+
|
|
37
|
+
def migrate(self, conn) -> None:
|
|
38
|
+
"""Create the taskfence_* tables in the application's database (idempotent)."""
|
|
39
|
+
self.store.migrate(conn)
|
|
40
|
+
|
|
41
|
+
def transaction(self, conn) -> AbstractContextManager[None]:
|
|
42
|
+
"""Open a write transaction suitable for `guard()`; commits on success, rolls back on error."""
|
|
43
|
+
return self.store.transaction(conn)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ control plane (embedded mode)
|
|
46
|
+
|
|
47
|
+
def grant(self, conn, *, principal: str, operation: str, resources: list[str],
|
|
48
|
+
grant_id: str | None = None) -> Grant:
|
|
49
|
+
"""Create a grant at epoch 1. `resources` may contain "*" for any resource."""
|
|
50
|
+
if not resources:
|
|
51
|
+
raise ValueError("resources must not be empty; use ['*'] to allow any resource")
|
|
52
|
+
gid = grant_id or "g_" + secrets.token_hex(8)
|
|
53
|
+
with self.store.errors(), self.store.owned_or_joined(conn):
|
|
54
|
+
self.store.create_grant(conn, gid, principal, operation, list(resources))
|
|
55
|
+
return Grant(gid, principal, operation, 1, tuple(sorted(resources)))
|
|
56
|
+
|
|
57
|
+
def revoke(self, conn, grant_id: str) -> Revocation:
|
|
58
|
+
"""Kill every token issued for this grant so far. Idempotent.
|
|
59
|
+
|
|
60
|
+
If a guarded transaction for this grant is open, this call waits for it
|
|
61
|
+
(bounded by that transaction, not by the task).
|
|
62
|
+
"""
|
|
63
|
+
with self.store.errors(), self.store.owned_or_joined(conn) as owned:
|
|
64
|
+
self._embedded_row(conn, grant_id)
|
|
65
|
+
min_epoch, seq = self.store.revoke(conn, grant_id)
|
|
66
|
+
return Revocation(grant_id, owned, min_epoch, seq)
|
|
67
|
+
|
|
68
|
+
def regrant(self, conn, grant_id: str, *, resources: list[str] | None = None) -> Grant:
|
|
69
|
+
"""Start a new epoch. Tokens from earlier epochs stay dead."""
|
|
70
|
+
with self.store.errors(), self.store.owned_or_joined(conn):
|
|
71
|
+
f = self._embedded_row(conn, grant_id)
|
|
72
|
+
new_epoch, _ = self.store.regrant(conn, grant_id, resources) # computed under the lock
|
|
73
|
+
scope = self.store.scope(conn, grant_id)
|
|
74
|
+
return Grant(grant_id, f["principal"], f["operation"], new_epoch, scope)
|
|
75
|
+
|
|
76
|
+
def cancel_task(self, conn, task_id: str) -> int:
|
|
77
|
+
"""Fence a task: none of its operations can commit afterwards. Returns the commit seq."""
|
|
78
|
+
with self.store.errors(), self.store.owned_or_joined(conn):
|
|
79
|
+
return self.store.cancel_task(conn, task_id)
|
|
80
|
+
|
|
81
|
+
def cancel_operation(self, conn, operation_id: str, *, task_id: str) -> bool:
|
|
82
|
+
"""Tombstone one operation. Returns False if it had already committed."""
|
|
83
|
+
with self.store.errors(), self.store.owned_or_joined(conn):
|
|
84
|
+
return self.store.cancel_operation(conn, operation_id, task_id)
|
|
85
|
+
|
|
86
|
+
def _embedded_row(self, conn, grant_id: str) -> dict[str, Any]:
|
|
87
|
+
f = self.store.fence_row(conn, grant_id)
|
|
88
|
+
if f is None:
|
|
89
|
+
raise KeyError(f"unknown grant {grant_id!r}")
|
|
90
|
+
if f["mode"] != "embedded":
|
|
91
|
+
raise UsageError(f"grant {grant_id!r} is federated; change it through apply_install/apply_fence")
|
|
92
|
+
return f
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ tokens
|
|
95
|
+
|
|
96
|
+
def issue(self, conn, grant_id: str, *, task_id: str, ttl_s: int = 3600) -> str:
|
|
97
|
+
"""Admit a task: return a signed token bound to the grant's current epoch."""
|
|
98
|
+
f = self._embedded_row(conn, grant_id)
|
|
99
|
+
if f["min_epoch"] > f["epoch"]:
|
|
100
|
+
raise Denied(Code.STALE_EPOCH, "grant is revoked")
|
|
101
|
+
return self.mint(grant_id=grant_id, principal=f["principal"], operation=f["operation"],
|
|
102
|
+
epoch=f["epoch"], task_id=task_id, ttl_s=ttl_s)
|
|
103
|
+
|
|
104
|
+
def mint(self, *, grant_id: str, principal: str, operation: str, epoch: int, task_id: str,
|
|
105
|
+
ttl_s: int = 3600) -> str:
|
|
106
|
+
"""Sign a token directly. Used by a grant authority in federated mode."""
|
|
107
|
+
now = int(self.clock())
|
|
108
|
+
return sign(Claims(grant_id, principal, operation, int(epoch), task_id, self.keys.signing_kid,
|
|
109
|
+
now, now + int(ttl_s)), self.keys)
|
|
110
|
+
|
|
111
|
+
def verify(self, token: str) -> Claims:
|
|
112
|
+
return verify(token, self.keys, now=self.clock, leeway_s=self.leeway_s)
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------ the guard
|
|
115
|
+
|
|
116
|
+
def guard(self, conn, token: str, *, operation: str, resource: str | None, operation_id: str,
|
|
117
|
+
predicate: Predicate | None = None) -> Admission:
|
|
118
|
+
"""Authorize an effect inside the transaction that commits it.
|
|
119
|
+
|
|
120
|
+
Call this inside an open transaction, before or after the effect's own
|
|
121
|
+
statements but before COMMIT. It raises `Denied`, `Duplicate` or `Retry`;
|
|
122
|
+
on any of them the caller must roll back. It never commits or rolls back
|
|
123
|
+
by itself.
|
|
124
|
+
"""
|
|
125
|
+
self.store.require_transaction(conn)
|
|
126
|
+
claims = self.verify(token)
|
|
127
|
+
if claims.operation != operation:
|
|
128
|
+
raise Denied(Code.OPERATION_MISMATCH, f"token is for {claims.operation!r}")
|
|
129
|
+
ctx = {"task_id": claims.task_id, "operation_id": operation_id}
|
|
130
|
+
_hooks.hook("guard.before_fence", **ctx)
|
|
131
|
+
with self.store.errors():
|
|
132
|
+
code = self.store.lock_and_check_fence(conn, claims.grant_id, claims.principal, operation, claims.epoch)
|
|
133
|
+
if code is not None:
|
|
134
|
+
raise Denied(code)
|
|
135
|
+
if not self.store.in_scope(conn, claims.grant_id, resource):
|
|
136
|
+
raise Denied(Code.RESOURCE_NOT_IN_SCOPE, str(resource))
|
|
137
|
+
if self.store.task_cancelled(conn, claims.task_id):
|
|
138
|
+
raise Denied(Code.TASK_CANCELLED, claims.task_id)
|
|
139
|
+
if predicate is not None and not predicate(conn, claims, resource):
|
|
140
|
+
raise Denied(Code.PREDICATE_FAILED)
|
|
141
|
+
_hooks.hook("guard.after_fence", **ctx)
|
|
142
|
+
seq = self.store.insert_op(conn, operation_id, claims.task_id, claims.grant_id, claims.epoch, resource)
|
|
143
|
+
if isinstance(seq, Code):
|
|
144
|
+
raise Denied(seq, operation_id)
|
|
145
|
+
_hooks.hook("guard.after_insert", **ctx)
|
|
146
|
+
return Admission(operation_id, claims.task_id, claims.grant_id, claims.epoch, resource, seq)
|
|
147
|
+
|
|
148
|
+
def fenced(self, *, operation: str, resource: Callable[..., str | None] | str | None,
|
|
149
|
+
operation_id: Callable[..., str], predicate: Predicate | None = None) -> Callable[[F], F]:
|
|
150
|
+
"""Decorator: run `fn(conn, **kwargs)` in a transaction that `guard()` admitted.
|
|
151
|
+
|
|
152
|
+
The wrapped function is called as `fn(conn, token=..., **kwargs)`; `resource`
|
|
153
|
+
and `operation_id` are callables that receive the same keyword arguments
|
|
154
|
+
(without `token`). The function also receives `admission=`.
|
|
155
|
+
"""
|
|
156
|
+
def deco(fn: F) -> F:
|
|
157
|
+
@functools.wraps(fn)
|
|
158
|
+
def wrapper(conn, *, token: str, **kwargs: Any) -> Any:
|
|
159
|
+
res = resource(**kwargs) if callable(resource) else resource
|
|
160
|
+
oid = operation_id(**kwargs)
|
|
161
|
+
with self.transaction(conn):
|
|
162
|
+
adm = self.guard(conn, token, operation=operation, resource=res, operation_id=oid,
|
|
163
|
+
predicate=predicate)
|
|
164
|
+
return fn(conn, admission=adm, **kwargs)
|
|
165
|
+
return wrapper # type: ignore[return-value]
|
|
166
|
+
return deco
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------ federated owner side
|
|
169
|
+
|
|
170
|
+
def apply_install(self, conn, msg: dict[str, Any]) -> Receipt:
|
|
171
|
+
"""INSTALL {msg_id, grant_id, principal, operation, epoch, resources} from a grant authority."""
|
|
172
|
+
return self._apply(conn, {**msg, "kind": "INSTALL"})
|
|
173
|
+
|
|
174
|
+
def apply_fence(self, conn, msg: dict[str, Any]) -> Receipt:
|
|
175
|
+
"""FENCE {msg_id, grant_id, principal, operation, fence_epoch}: fence_epoch is the new minimum."""
|
|
176
|
+
return self._apply(conn, {**msg, "kind": "FENCE"})
|
|
177
|
+
|
|
178
|
+
def _apply(self, conn, msg: dict[str, Any]) -> Receipt:
|
|
179
|
+
if not isinstance(msg.get("msg_id"), str):
|
|
180
|
+
raise ValueError("msg_id is required")
|
|
181
|
+
with self.store.errors(), self.store.owned_or_joined(conn):
|
|
182
|
+
r = self.store.apply_message(conn, msg)
|
|
183
|
+
return Receipt(r["msg_id"], r["kind"], r["grant_id"], r["epoch"], bool(r["applied"]), r["min_epoch"],
|
|
184
|
+
r["duplicate"])
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------ outbox
|
|
187
|
+
|
|
188
|
+
def enqueue(self, conn, operation_id: str, topic: str, payload: Any) -> int:
|
|
189
|
+
"""Record an external call to make after commit. Exists iff the guarded transaction commits."""
|
|
190
|
+
return self.store.outbox_enqueue(conn, operation_id, topic, payload)
|
|
191
|
+
|
|
192
|
+
def drain_outbox(self, conn, handler: Callable[[dict[str, Any]], None], *, limit: int = 100) -> int:
|
|
193
|
+
"""Deliver pending outbox entries at least once. `handler` must be idempotent."""
|
|
194
|
+
n = 0
|
|
195
|
+
for entry in self.store.outbox_pending(conn, limit):
|
|
196
|
+
handler(entry)
|
|
197
|
+
with self.store.owned_or_joined(conn):
|
|
198
|
+
self.store.outbox_mark_delivered(conn, entry["id"])
|
|
199
|
+
n += 1
|
|
200
|
+
return n
|
taskfence/outcomes.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Outcome types and denial codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Code(StrEnum):
|
|
10
|
+
BAD_TOKEN = "BAD_TOKEN" # malformed token
|
|
11
|
+
BAD_SIGNATURE = "BAD_SIGNATURE"
|
|
12
|
+
UNKNOWN_KEY = "UNKNOWN_KEY" # kid not in the key ring (e.g. removed after compromise)
|
|
13
|
+
EXPIRED = "EXPIRED"
|
|
14
|
+
NO_GRANT = "NO_GRANT" # no fence row for this grant
|
|
15
|
+
STALE_EPOCH = "STALE_EPOCH" # token epoch below the fence: revoked or superseded
|
|
16
|
+
FUTURE_EPOCH = "FUTURE_EPOCH" # token epoch above any epoch issued here
|
|
17
|
+
PRINCIPAL_MISMATCH = "PRINCIPAL_MISMATCH"
|
|
18
|
+
OPERATION_MISMATCH = "OPERATION_MISMATCH"
|
|
19
|
+
RESOURCE_NOT_IN_SCOPE = "RESOURCE_NOT_IN_SCOPE"
|
|
20
|
+
TASK_CANCELLED = "TASK_CANCELLED"
|
|
21
|
+
OPERATION_CANCELLED = "OPERATION_CANCELLED"
|
|
22
|
+
PREDICATE_FAILED = "PREDICATE_FAILED" # application predicate returned False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskFenceError(Exception):
|
|
26
|
+
"""Base class for all TaskFence errors."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Denied(TaskFenceError):
|
|
30
|
+
"""The effect must not commit. Roll back the transaction."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, code: Code, detail: str = ""):
|
|
33
|
+
self.code = Code(code)
|
|
34
|
+
self.detail = detail
|
|
35
|
+
super().__init__(f"{self.code}{': ' + detail if detail else ''}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Duplicate(TaskFenceError):
|
|
39
|
+
"""This operation_id already committed. Roll back; the earlier effect stands."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, operation_id: str):
|
|
42
|
+
self.operation_id = operation_id
|
|
43
|
+
super().__init__(f"operation already committed: {operation_id}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Retry(TaskFenceError):
|
|
47
|
+
"""Transient database conflict (lock timeout, serialization failure). Safe to retry."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UsageError(TaskFenceError):
|
|
51
|
+
"""The library was called in a way that would void its guarantee."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Admission:
|
|
56
|
+
"""Returned by `guard()`. Provisional until the caller's transaction commits."""
|
|
57
|
+
|
|
58
|
+
operation_id: str
|
|
59
|
+
task_id: str
|
|
60
|
+
grant_id: str
|
|
61
|
+
epoch: int
|
|
62
|
+
resource: str | None
|
|
63
|
+
seq: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Grant:
|
|
68
|
+
grant_id: str
|
|
69
|
+
principal: str
|
|
70
|
+
operation: str
|
|
71
|
+
epoch: int
|
|
72
|
+
resources: tuple[str, ...]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Revocation:
|
|
77
|
+
grant_id: str
|
|
78
|
+
effective: bool # embedded mode: True once this call returns
|
|
79
|
+
min_epoch: int
|
|
80
|
+
seq: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Receipt:
|
|
85
|
+
"""Owner acknowledgment of a federated INSTALL or FENCE message."""
|
|
86
|
+
|
|
87
|
+
msg_id: str
|
|
88
|
+
kind: str
|
|
89
|
+
grant_id: str
|
|
90
|
+
epoch: int
|
|
91
|
+
applied: bool
|
|
92
|
+
min_epoch: int
|
|
93
|
+
duplicate: bool
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""PostgreSQL store (psycopg 3). Not part of the paper's evaluation; see docs/DESIGN.md §6.
|
|
2
|
+
|
|
3
|
+
Locking, per grant:
|
|
4
|
+
|
|
5
|
+
* ``lock="shared"`` (default). A guard takes a *shared* transaction-level
|
|
6
|
+
advisory lock on the grant, then reads the fence row ``FOR SHARE``. Revoke and
|
|
7
|
+
regrant take the *exclusive* advisory lock, then UPDATE the fence row. Guards
|
|
8
|
+
for one grant run in parallel. Advisory locks queue fairly, so a steady stream
|
|
9
|
+
of guards cannot starve a revocation (plain ``FOR SHARE`` row locks can).
|
|
10
|
+
* ``lock="exclusive"``. A guard reads the fence row ``FOR UPDATE``. Guards for
|
|
11
|
+
one grant serialize, as in D5.
|
|
12
|
+
|
|
13
|
+
Either way, a revocation waits for in-flight guarded transactions of that grant,
|
|
14
|
+
and any guard that locks after the revocation commits reads the new floor. Under
|
|
15
|
+
READ COMMITTED each statement takes a fresh snapshot after the lock is granted.
|
|
16
|
+
Under REPEATABLE READ and SERIALIZABLE, locking a row changed after the snapshot
|
|
17
|
+
raises a serialization failure, which surfaces as ``Retry``.
|
|
18
|
+
|
|
19
|
+
Cancellation: the guard upserts the task's row in ``taskfence_tasks`` (row lock
|
|
20
|
+
held until commit) and reads its ``cancelled`` flag. ``cancel_task`` upserts the
|
|
21
|
+
same row, so the two serialize.
|
|
22
|
+
|
|
23
|
+
``seq`` comes from a sequence. It is unique but is not commit order. The auditor
|
|
24
|
+
uses commit timestamps instead (requires ``track_commit_timestamp = on``).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from typing import Any, Iterator, Literal
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import psycopg
|
|
35
|
+
from psycopg import errors as pgerr
|
|
36
|
+
from psycopg.pq import TransactionStatus
|
|
37
|
+
except ImportError as e: # pragma: no cover
|
|
38
|
+
raise ImportError("PostgresStore needs psycopg 3: pip install 'taskfence[postgres]'") from e
|
|
39
|
+
|
|
40
|
+
from ..outcomes import Code, Duplicate, Retry, UsageError
|
|
41
|
+
|
|
42
|
+
SCHEMA = """
|
|
43
|
+
CREATE SEQUENCE IF NOT EXISTS taskfence_seq;
|
|
44
|
+
CREATE TABLE IF NOT EXISTS taskfence_fences (
|
|
45
|
+
grant_id text PRIMARY KEY,
|
|
46
|
+
principal text NOT NULL,
|
|
47
|
+
operation text NOT NULL,
|
|
48
|
+
mode text NOT NULL CHECK (mode IN ('embedded', 'federated')),
|
|
49
|
+
epoch bigint, -- highest issued epoch; NULL in federated mode
|
|
50
|
+
min_epoch bigint NOT NULL, -- tokens below this are dead; only ever increases
|
|
51
|
+
updated_at timestamptz NOT NULL DEFAULT now());
|
|
52
|
+
CREATE TABLE IF NOT EXISTS taskfence_scope (
|
|
53
|
+
grant_id text NOT NULL REFERENCES taskfence_fences ON DELETE CASCADE,
|
|
54
|
+
resource text NOT NULL, PRIMARY KEY (grant_id, resource));
|
|
55
|
+
CREATE TABLE IF NOT EXISTS taskfence_fence_log (
|
|
56
|
+
seq bigint PRIMARY KEY, grant_id text NOT NULL, kind text NOT NULL,
|
|
57
|
+
epoch bigint, min_epoch bigint NOT NULL, at timestamptz NOT NULL DEFAULT now());
|
|
58
|
+
CREATE TABLE IF NOT EXISTS taskfence_ops (
|
|
59
|
+
operation_id text PRIMARY KEY,
|
|
60
|
+
state text NOT NULL CHECK (state IN ('COMMITTED', 'CANCELLED')),
|
|
61
|
+
task_id text NOT NULL,
|
|
62
|
+
grant_id text,
|
|
63
|
+
epoch bigint,
|
|
64
|
+
resource text,
|
|
65
|
+
seq bigint NOT NULL UNIQUE,
|
|
66
|
+
created_at timestamptz NOT NULL DEFAULT now());
|
|
67
|
+
CREATE TABLE IF NOT EXISTS taskfence_tasks (
|
|
68
|
+
task_id text PRIMARY KEY, cancelled boolean NOT NULL DEFAULT false, cancelled_seq bigint);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS taskfence_cancel_log ( -- append-only; the auditor reads its commit timestamps
|
|
70
|
+
seq bigint PRIMARY KEY, task_id text NOT NULL, at timestamptz NOT NULL DEFAULT now());
|
|
71
|
+
CREATE TABLE IF NOT EXISTS taskfence_receipts (
|
|
72
|
+
msg_id text PRIMARY KEY, kind text NOT NULL, grant_id text NOT NULL, epoch bigint NOT NULL,
|
|
73
|
+
applied boolean NOT NULL, min_epoch bigint NOT NULL, seq bigint NOT NULL);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS taskfence_outbox (
|
|
75
|
+
id bigserial PRIMARY KEY,
|
|
76
|
+
operation_id text NOT NULL REFERENCES taskfence_ops(operation_id),
|
|
77
|
+
topic text NOT NULL, payload jsonb NOT NULL, delivered_at timestamptz);
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
_RETRYABLE = (pgerr.SerializationFailure, pgerr.DeadlockDetected, pgerr.LockNotAvailable,
|
|
81
|
+
pgerr.QueryCanceled)
|
|
82
|
+
# Advisory-lock namespace: hashtext('taskfence') would also do; a constant keeps it obvious.
|
|
83
|
+
_LOCK_CLASS = 0x7446 # "tF"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def connect(dsn: str, **kwargs: Any) -> "psycopg.Connection":
|
|
87
|
+
"""Open a connection configured the way TaskFence expects (autocommit; explicit transactions)."""
|
|
88
|
+
return psycopg.connect(dsn, autocommit=True, **kwargs)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def translate_errors() -> Iterator[None]:
|
|
93
|
+
try:
|
|
94
|
+
yield
|
|
95
|
+
except _RETRYABLE as e:
|
|
96
|
+
raise Retry(f"{type(e).__name__}: {e}") from e
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _status(conn) -> TransactionStatus:
|
|
100
|
+
return conn.info.transaction_status
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PostgresStore:
|
|
104
|
+
name = "postgres"
|
|
105
|
+
|
|
106
|
+
def __init__(self, lock: Literal["shared", "exclusive"] = "shared"):
|
|
107
|
+
if lock not in ("shared", "exclusive"):
|
|
108
|
+
raise ValueError("lock must be 'shared' or 'exclusive'")
|
|
109
|
+
self.lock = lock
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def errors():
|
|
113
|
+
return translate_errors()
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------- transactions
|
|
116
|
+
|
|
117
|
+
def migrate(self, conn) -> None:
|
|
118
|
+
if _status(conn) != TransactionStatus.IDLE:
|
|
119
|
+
raise UsageError("migrate() must not run inside an open transaction")
|
|
120
|
+
with conn.transaction():
|
|
121
|
+
conn.execute(SCHEMA)
|
|
122
|
+
|
|
123
|
+
@contextmanager
|
|
124
|
+
def transaction(self, conn) -> Iterator[None]:
|
|
125
|
+
if _status(conn) != TransactionStatus.IDLE:
|
|
126
|
+
raise UsageError("a transaction is already open on this connection")
|
|
127
|
+
with translate_errors(), conn.transaction():
|
|
128
|
+
yield
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def owned_or_joined(self, conn) -> Iterator[bool]:
|
|
132
|
+
if _status(conn) == TransactionStatus.INTRANS:
|
|
133
|
+
yield False
|
|
134
|
+
else:
|
|
135
|
+
with self.transaction(conn):
|
|
136
|
+
yield True
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def require_transaction(conn) -> None:
|
|
140
|
+
if _status(conn) != TransactionStatus.INTRANS:
|
|
141
|
+
raise UsageError("guard() must run inside the transaction that commits the effect; "
|
|
142
|
+
"use fence.transaction(conn)")
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def bump(conn) -> int:
|
|
146
|
+
return conn.execute("SELECT nextval('taskfence_seq')").fetchone()[0]
|
|
147
|
+
|
|
148
|
+
def _log(self, conn, grant_id: str, kind: str, epoch: int | None, min_epoch: int) -> int:
|
|
149
|
+
seq = self.bump(conn)
|
|
150
|
+
conn.execute("INSERT INTO taskfence_fence_log (seq, grant_id, kind, epoch, min_epoch)"
|
|
151
|
+
" VALUES (%s, %s, %s, %s, %s)", (seq, grant_id, kind, epoch, min_epoch))
|
|
152
|
+
return seq
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _grant_lock(conn, grant_id: str, *, exclusive: bool) -> None:
|
|
156
|
+
fn = "pg_advisory_xact_lock" if exclusive else "pg_advisory_xact_lock_shared"
|
|
157
|
+
conn.execute(f"SELECT {fn}(%s, hashtext(%s))", (_LOCK_CLASS, grant_id))
|
|
158
|
+
|
|
159
|
+
def _writer_lock(self, conn, grant_id: str) -> None:
|
|
160
|
+
"""Taken by every control-plane change to a fence row."""
|
|
161
|
+
if self.lock == "shared":
|
|
162
|
+
self._grant_lock(conn, grant_id, exclusive=True)
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------- reads
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def fence_row(conn, grant_id: str, *, lock: str = "") -> dict[str, Any] | None:
|
|
168
|
+
r = conn.execute("SELECT grant_id, principal, operation, mode, epoch, min_epoch FROM taskfence_fences"
|
|
169
|
+
f" WHERE grant_id = %s {lock}", (grant_id,)).fetchone()
|
|
170
|
+
return None if r is None else dict(zip(("grant_id", "principal", "operation", "mode", "epoch",
|
|
171
|
+
"min_epoch"), r))
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def scope(conn, grant_id: str) -> tuple[str, ...]:
|
|
175
|
+
rows = conn.execute("SELECT resource FROM taskfence_scope WHERE grant_id = %s ORDER BY resource",
|
|
176
|
+
(grant_id,)).fetchall()
|
|
177
|
+
return tuple(r[0] for r in rows)
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------- embedded admin
|
|
180
|
+
|
|
181
|
+
def create_grant(self, conn, grant_id: str, principal: str, operation: str, resources: list[str]) -> int:
|
|
182
|
+
conn.execute("INSERT INTO taskfence_fences (grant_id, principal, operation, mode, epoch, min_epoch)"
|
|
183
|
+
" VALUES (%s, %s, %s, 'embedded', 1, 1)", (grant_id, principal, operation))
|
|
184
|
+
with conn.cursor() as cur:
|
|
185
|
+
cur.executemany("INSERT INTO taskfence_scope VALUES (%s, %s)", [(grant_id, r) for r in resources])
|
|
186
|
+
return self._log(conn, grant_id, "GRANT", 1, 1)
|
|
187
|
+
|
|
188
|
+
def revoke(self, conn, grant_id: str) -> tuple[int, int]:
|
|
189
|
+
self._writer_lock(conn, grant_id)
|
|
190
|
+
cur = conn.execute("UPDATE taskfence_fences SET min_epoch = GREATEST(min_epoch, epoch + 1),"
|
|
191
|
+
" updated_at = now() WHERE grant_id = %s RETURNING epoch, min_epoch", (grant_id,))
|
|
192
|
+
e, floor = cur.fetchone()
|
|
193
|
+
return floor, self._log(conn, grant_id, "REVOKE", e, floor)
|
|
194
|
+
|
|
195
|
+
def regrant(self, conn, grant_id: str, resources: list[str] | None) -> tuple[int, int]:
|
|
196
|
+
self._writer_lock(conn, grant_id)
|
|
197
|
+
new_epoch = conn.execute("UPDATE taskfence_fences SET epoch = GREATEST(epoch, min_epoch - 1) + 1,"
|
|
198
|
+
" min_epoch = GREATEST(epoch, min_epoch - 1) + 1, updated_at = now()"
|
|
199
|
+
" WHERE grant_id = %s RETURNING epoch", (grant_id,)).fetchone()[0]
|
|
200
|
+
if resources is not None:
|
|
201
|
+
self._replace_scope(conn, grant_id, resources)
|
|
202
|
+
return new_epoch, self._log(conn, grant_id, "REGRANT", new_epoch, new_epoch)
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _replace_scope(conn, grant_id: str, resources: list[str]) -> None:
|
|
206
|
+
conn.execute("DELETE FROM taskfence_scope WHERE grant_id = %s", (grant_id,))
|
|
207
|
+
with conn.cursor() as cur:
|
|
208
|
+
cur.executemany("INSERT INTO taskfence_scope VALUES (%s, %s)", [(grant_id, r) for r in resources])
|
|
209
|
+
|
|
210
|
+
def cancel_task(self, conn, task_id: str) -> int:
|
|
211
|
+
# Lock first, then take seq, so seq order matches commit order against conflicting guards.
|
|
212
|
+
conn.execute("INSERT INTO taskfence_tasks (task_id, cancelled) VALUES (%s, true)"
|
|
213
|
+
" ON CONFLICT (task_id) DO UPDATE SET cancelled = true WHERE NOT taskfence_tasks.cancelled",
|
|
214
|
+
(task_id,))
|
|
215
|
+
seq = self.bump(conn)
|
|
216
|
+
conn.execute("UPDATE taskfence_tasks SET cancelled_seq = COALESCE(cancelled_seq, %s) WHERE task_id = %s",
|
|
217
|
+
(seq, task_id))
|
|
218
|
+
conn.execute("INSERT INTO taskfence_cancel_log (seq, task_id) VALUES (%s, %s)", (seq, task_id))
|
|
219
|
+
return seq
|
|
220
|
+
|
|
221
|
+
def cancel_operation(self, conn, operation_id: str, task_id: str) -> bool:
|
|
222
|
+
seq = self.bump(conn)
|
|
223
|
+
r = conn.execute("INSERT INTO taskfence_ops (operation_id, state, task_id, seq)"
|
|
224
|
+
" VALUES (%s, 'CANCELLED', %s, %s) ON CONFLICT (operation_id) DO NOTHING RETURNING 1",
|
|
225
|
+
(operation_id, task_id, seq)).fetchone()
|
|
226
|
+
if r is not None:
|
|
227
|
+
return True
|
|
228
|
+
prior = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = %s", (operation_id,)).fetchone()
|
|
229
|
+
return prior is not None and prior[0] == "CANCELLED"
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------- guard steps
|
|
232
|
+
|
|
233
|
+
def lock_and_check_fence(self, conn, grant_id: str, principal: str, operation: str, epoch: int) -> Code | None:
|
|
234
|
+
if self.lock == "shared":
|
|
235
|
+
self._grant_lock(conn, grant_id, exclusive=False)
|
|
236
|
+
# Federated: an authentic newer epoch proves a regrant, so raise the floor (D5 semantics).
|
|
237
|
+
adv = conn.execute("UPDATE taskfence_fences SET min_epoch = %s, updated_at = now()"
|
|
238
|
+
" WHERE grant_id = %s AND mode = 'federated' AND principal = %s AND operation = %s"
|
|
239
|
+
" AND min_epoch < %s", (epoch, grant_id, principal, operation, epoch))
|
|
240
|
+
if adv.rowcount == 1:
|
|
241
|
+
self._log(conn, grant_id, "ADVANCE", epoch, epoch)
|
|
242
|
+
f = self.fence_row(conn, grant_id, lock="FOR SHARE" if self.lock == "shared" else "FOR UPDATE")
|
|
243
|
+
if f is None:
|
|
244
|
+
return Code.NO_GRANT
|
|
245
|
+
if principal != f["principal"]:
|
|
246
|
+
return Code.PRINCIPAL_MISMATCH
|
|
247
|
+
if operation != f["operation"]:
|
|
248
|
+
return Code.OPERATION_MISMATCH
|
|
249
|
+
if epoch < f["min_epoch"]:
|
|
250
|
+
return Code.STALE_EPOCH
|
|
251
|
+
if f["epoch"] is not None and epoch > f["epoch"]:
|
|
252
|
+
return Code.FUTURE_EPOCH
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def in_scope(conn, grant_id: str, resource: str | None) -> bool:
|
|
257
|
+
return conn.execute(
|
|
258
|
+
"SELECT 1 FROM taskfence_scope WHERE grant_id = %s AND (resource = '*' OR resource = %s)",
|
|
259
|
+
(grant_id, resource)).fetchone() is not None
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def task_cancelled(conn, task_id: str) -> bool:
|
|
263
|
+
# The row lock is held until commit, so cancel_task() (which writes this row) serializes with us.
|
|
264
|
+
conn.execute("INSERT INTO taskfence_tasks (task_id) VALUES (%s) ON CONFLICT (task_id) DO NOTHING", (task_id,))
|
|
265
|
+
return conn.execute("SELECT cancelled FROM taskfence_tasks WHERE task_id = %s FOR SHARE",
|
|
266
|
+
(task_id,)).fetchone()[0]
|
|
267
|
+
|
|
268
|
+
def insert_op(self, conn, operation_id: str, task_id: str, grant_id: str, epoch: int,
|
|
269
|
+
resource: str | None) -> int | Code:
|
|
270
|
+
seq = self.bump(conn)
|
|
271
|
+
r = conn.execute("INSERT INTO taskfence_ops (operation_id, state, task_id, grant_id, epoch, resource, seq)"
|
|
272
|
+
" VALUES (%s, 'COMMITTED', %s, %s, %s, %s, %s) ON CONFLICT (operation_id) DO NOTHING"
|
|
273
|
+
" RETURNING seq", (operation_id, task_id, grant_id, epoch, resource, seq)).fetchone()
|
|
274
|
+
if r is not None:
|
|
275
|
+
return seq
|
|
276
|
+
prior = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = %s", (operation_id,)).fetchone()
|
|
277
|
+
if prior is not None and prior[0] == "CANCELLED":
|
|
278
|
+
return Code.OPERATION_CANCELLED
|
|
279
|
+
raise Duplicate(operation_id)
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------- federated owner side
|
|
282
|
+
|
|
283
|
+
def apply_message(self, conn, msg: dict[str, Any]) -> dict[str, Any]:
|
|
284
|
+
cols = ("msg_id", "kind", "grant_id", "epoch", "applied", "min_epoch", "seq")
|
|
285
|
+
kind, g = msg["kind"], msg["grant_id"]
|
|
286
|
+
self._writer_lock(conn, g)
|
|
287
|
+
prior = conn.execute(f"SELECT {', '.join(cols)} FROM taskfence_receipts WHERE msg_id = %s",
|
|
288
|
+
(msg["msg_id"],)).fetchone()
|
|
289
|
+
if prior is not None:
|
|
290
|
+
return {**dict(zip(cols, prior)), "duplicate": True}
|
|
291
|
+
e = int(msg["epoch"] if kind == "INSTALL" else msg["fence_epoch"])
|
|
292
|
+
cur = self.fence_row(conn, g, lock="FOR UPDATE")
|
|
293
|
+
if cur is not None and cur["mode"] != "federated":
|
|
294
|
+
raise UsageError(f"grant {g!r} is an embedded grant; federated messages cannot change it")
|
|
295
|
+
applied = cur is None or e > cur["min_epoch"]
|
|
296
|
+
if cur is None:
|
|
297
|
+
conn.execute("INSERT INTO taskfence_fences (grant_id, principal, operation, mode, epoch, min_epoch)"
|
|
298
|
+
" VALUES (%s, %s, %s, 'federated', NULL, %s)", (g, msg["principal"], msg["operation"], e))
|
|
299
|
+
elif applied:
|
|
300
|
+
conn.execute("UPDATE taskfence_fences SET min_epoch = %s, updated_at = now() WHERE grant_id = %s", (e, g))
|
|
301
|
+
if kind == "INSTALL" and applied:
|
|
302
|
+
self._replace_scope(conn, g, list(msg.get("resources", [])))
|
|
303
|
+
floor = self.fence_row(conn, g)["min_epoch"]
|
|
304
|
+
seq = self._log(conn, g, kind if applied else f"{kind}_NOOP", e, floor)
|
|
305
|
+
conn.execute("INSERT INTO taskfence_receipts VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
306
|
+
(msg["msg_id"], kind, g, e, applied, floor, seq))
|
|
307
|
+
return {"msg_id": msg["msg_id"], "kind": kind, "grant_id": g, "epoch": e, "applied": applied,
|
|
308
|
+
"min_epoch": floor, "seq": seq, "duplicate": False}
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------- outbox
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def outbox_enqueue(conn, operation_id: str, topic: str, payload: Any) -> int:
|
|
314
|
+
r = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = %s", (operation_id,)).fetchone()
|
|
315
|
+
if r is None or r[0] != "COMMITTED":
|
|
316
|
+
raise UsageError("enqueue only after guard() admitted this operation_id, in the same transaction")
|
|
317
|
+
return conn.execute("INSERT INTO taskfence_outbox (operation_id, topic, payload) VALUES (%s, %s, %s)"
|
|
318
|
+
" RETURNING id", (operation_id, topic, json.dumps(payload))).fetchone()[0]
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def outbox_pending(conn, limit: int) -> list[dict[str, Any]]:
|
|
322
|
+
rows = conn.execute("SELECT id, operation_id, topic, payload FROM taskfence_outbox"
|
|
323
|
+
" WHERE delivered_at IS NULL ORDER BY id LIMIT %s", (limit,)).fetchall()
|
|
324
|
+
return [{"id": r[0], "operation_id": r[1], "topic": r[2], "payload": r[3]} for r in rows]
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def outbox_mark_delivered(conn, entry_id: int) -> None:
|
|
328
|
+
conn.execute("UPDATE taskfence_outbox SET delivered_at = now() WHERE id = %s", (entry_id,))
|