Skip to content

Commit 8cf1c68

Browse files
Merge pull request #25 from Sreejay-Reddy/feat/event-log-0.5.0
feat: add sentinel_events append-only schema and TIMESTAMPTZ migration
2 parents f8277de + 97248c3 commit 8cf1c68

18 files changed

Lines changed: 434 additions & 36 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ The format loosely follows Keep a Changelog.
55

66
---
77

8+
## 0.5.0 — 2026-06-27
9+
10+
### Added
11+
- **`sentinel_events` append-only event log** — new table recording every state transition with `key`, `event`, `owner_id`, `fencing_token`, `metadata`, and `occurred_at`. Indexed on `(key, occurred_at, fencing_token)` for efficient history queries.
12+
- **`write_event` / `async_write_event`** — internal helpers that write events atomically within the same transaction as each state transition. Events and lease changes commit together or not at all.
13+
- **`SentinelEvent` enum** — typed event constants (`ACQUIRED`, `REJECTED`, `EXECUTING`, `COMPLETED`, `EXPIRED`, `RECONCILING`, `RESET`, `RELEASED`) used across sync and async paths.
14+
- **`history(conn, key, *, limit=50)`** — query the event log for any key, returns a list of `EventRecord` dataclasses ordered oldest first.
15+
- **`sen history <key>`** — CLI command to print the full execution history for a key directly from the terminal. Accepts `--limit` to cap results.
16+
- **Fencing token rotation on reconciliation**`reconcile()` now issues a new fencing token via `nextval('sentinel_token_seq')` at the moment it transitions a lease to `reconciling`. The original worker's token is immediately invalidated.
17+
- **`RESET` and `RELEASED` events** — explicit events written when a lease is reset by the reconciler or explicitly released by the caller.
18+
19+
### Changed
20+
- `TIMESTAMPTZ` replaces `TIMESTAMP` across all lease columns for correct timezone-aware behaviour in distributed environments.
21+
22+
---
23+
824
## 0.4.2 — 2026-06-21
925

1026
### Added

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Sentinel ships with `sen`, a command-line tool for inspecting lease state direct
6363

6464
```bash
6565
sen inspect <key>
66+
sen history <key> --limit 20
6667
```
6768

6869
`sen` reads `DATABASE_URL` from your environment or a `.env` file automatically.
@@ -102,6 +103,8 @@ result = sentinel.once(
102103
)
103104
```
104105

106+
---
107+
105108
### Reading the result
106109

107110
```python
@@ -124,6 +127,29 @@ else:
124127

125128
---
126129

130+
## Execution History
131+
132+
Sentinel records every state transition to an append-only event log. Every acquire, rejection, execution start, completion, expiry, and reconciliation is written atomically with the lease change that caused it.
133+
134+
```bash
135+
sen history <key>
136+
sen history <key> --limit 20
137+
```
138+
139+
Example output:
140+
History for key: payment-order-789 (3 events)
141+
2026-06-27 14:02:01 acquired token=42 owner=worker-a
142+
2026-06-27 14:02:01 executing token=42 owner=worker-a
143+
2026-06-27 14:02:03 completed token=42 owner=worker-a
144+
145+
### What the event log tells you
146+
147+
The sequence of events is the ground truth for what happened to any execution key. A `reconciling` event followed by `acquired` means a new worker took over. A `reconciling` event followed by `completed` means the original worker finished inside the uncertainty window. An `expired` event means the worker raised an exception and the lease was collapsed immediately.
148+
149+
The log does not resolve uncertainty — it records it honestly.
150+
151+
---
152+
127153
## Async
128154

129155
If you're working in an async context, use `AsyncSentinel`:
@@ -254,7 +280,6 @@ The core execution semantics are stable as of 0.4.0. Reconciliation tooling and
254280
## Roadmap
255281

256282
- Redis cache for better throughput
257-
- Append-only execution event log (`sentinel_events`)
258283
- FastAPI integration
259284
- Correlate — cross-service execution observability
260285
- Stronger reconciliation tooling

ROADMAP.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,9 @@ The focus is to keep the core small, explicit, and reliable rather than continuo
4747

4848
## Near Term (0.5.0)
4949

50-
- [ ] `sentinel_events` append-only event log written in the same transaction as state transitions
51-
- [ ] FastAPI integration after async core stabilizes
52-
- [ ] Expanded test coverage for edge cases and contention scenarios
53-
- [ ] Improved examples and integration guides
50+
- [x] `sentinel_events` append-only event log written in the same transaction as state transitions
51+
- [x] Expanded test coverage for edge cases and contention scenarios
52+
- [x] Improved examples and integration guides
5453

5554
---
5655

@@ -59,6 +58,7 @@ The focus is to keep the core small, explicit, and reliable rather than continuo
5958
These are ideas being explored and are not guaranteed.
6059

6160
- [ ] Correlate — separate OSS library that reads `sentinel_events` for cross-service execution observability
61+
- [ ] FastAPI integration after async core stabilizes
6262
- [ ] Batched adaptive heartbeats — bucket-level batch `UPDATE` with adaptive interval
6363
- [ ] Additional framework adapters (Flask, Starlette)
6464
- [ ] Embeddable reconciliation dashboard

sentinel-py/pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "sentinel-coordination"
3-
version = "0.4.1"
3+
version = "0.5.0"
44
description = "PostgreSQL-backed execution coordination primitive for correctness-sensitive distributed work."
55
authors = [
66
{ name = "Sreejay Reddy", email = "reddysreejay@gmail.com" }
77
]
88
readme = "README.md"
99
requires-python = ">=3.9"
10-
dependencies = ["psycopg[binary]"]
10+
dependencies = ["psycopg[binary]", "python-dotenv"]
1111

1212
keywords = [
1313
"postgresql",
@@ -16,7 +16,10 @@ keywords = [
1616
"execution-coordination",
1717
"fault-tolerance",
1818
"concurrency",
19-
"fencing-tokens"
19+
"fencing-tokens",
20+
"append-only log",
21+
"execution history",
22+
"audit log"
2023
]
2124

2225
classifiers = [
@@ -40,7 +43,6 @@ sen = "sentinel.cli:main"
4043

4144
[project.optional-dependencies]
4245
django = ["django>=4.2"]
43-
cli = ["python-dotenv"]
4446

4547
[build-system]
4648
requires = ["setuptools>=61.0"]

sentinel-py/sentinel/async_core.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from .utils import get_owner_id, row_to_dict
33
from .result import AcquireResult, OperationResult, InspectResult
4+
from .events import SentinelEvent, async_write_event
45

56
async def acquire(conn, key, *, owner_id=None, ttl_ms=10000, hard_ttl_ms = None):
67

@@ -44,6 +45,7 @@ async def acquire(conn, key, *, owner_id=None, ttl_ms=10000, hard_ttl_ms = None)
4445

4546
if result is not None:
4647
row = row_to_dict(cur, result)
48+
await async_write_event(cur, key, SentinelEvent.ACQUIRED, owner_id=row["owner_id"], fencing_token=row["fencing_token"])
4749

4850
await conn.commit()
4951

@@ -71,6 +73,7 @@ async def acquire(conn, key, *, owner_id=None, ttl_ms=10000, hard_ttl_ms = None)
7173

7274
if result is not None:
7375
row = row_to_dict(cur, result)
76+
await async_write_event(cur, key, SentinelEvent.REJECTED, owner_id=row["owner_id"], fencing_token=row["fencing_token"])
7477

7578
await conn.commit()
7679

@@ -102,6 +105,7 @@ async def start_execution(conn, key, *, owner_id, fencing_token):
102105
success = result is not None
103106
if result is not None:
104107
row = row_to_dict(cur, result)
108+
await async_write_event(cur, key, SentinelEvent.EXECUTING, owner_id=owner_id, fencing_token=fencing_token)
105109

106110
await conn.commit()
107111
if row is None:
@@ -118,6 +122,8 @@ async def release(conn, key, *, owner_id, fencing_token):
118122
""", (key, owner_id, fencing_token))
119123

120124
success = await cur.fetchone() is not None
125+
if success:
126+
await async_write_event(cur, key, SentinelEvent.RELEASED, owner_id=owner_id, fencing_token=fencing_token)
121127

122128
await conn.commit()
123129
return OperationResult(success)
@@ -144,7 +150,8 @@ async def complete(conn, key, *, owner_id, fencing_token, execution_result=None)
144150
""", (serialized_result, key, owner_id, fencing_token))
145151

146152
success = await cur.fetchone() is not None
147-
153+
if success:
154+
await async_write_event(cur, key, SentinelEvent.COMPLETED, owner_id=owner_id, fencing_token=fencing_token)
148155
await conn.commit()
149156
return OperationResult(success)
150157

@@ -180,6 +187,8 @@ async def expire_lease(conn, key, *, owner_id, fencing_token):
180187
RETURNING 1;
181188
""", (key, owner_id, fencing_token))
182189
success = await cur.fetchone() is not None
190+
if success:
191+
await async_write_event(cur, key, SentinelEvent.EXPIRED, owner_id=owner_id, fencing_token=fencing_token)
183192
await conn.commit()
184193
return OperationResult(success)
185194

sentinel-py/sentinel/async_reconcilliation.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from sentinel.result import OperationResult
2+
from .events import SentinelEvent, async_write_event
23

34

45
class AsyncReconcile:
@@ -15,15 +16,17 @@ async def reconcile(self, key):
1516
UPDATE sentinel_leases
1617
SET
1718
status = 'reconciling',
18-
lease_updated_at = NOW()
19+
lease_updated_at = NOW(),
20+
fencing_token = nextval('sentinel_token_seq')
1921
WHERE key = %s
2022
AND status = 'executing'
2123
AND lease_expires_at < NOW()
2224
RETURNING 1;
2325
""", (key,))
2426

2527
success = await cur.fetchone() is not None
26-
28+
if success:
29+
await async_write_event(cur, key, SentinelEvent.RECONCILING)
2730
await conn.commit()
2831

2932
return OperationResult(success)
@@ -48,7 +51,8 @@ async def force_complete(self, key, execution_result):
4851
""", (execution_result, key))
4952

5053
success = await cur.fetchone() is not None
51-
54+
if success:
55+
await async_write_event(cur, key, SentinelEvent.COMPLETED)
5256
await conn.commit()
5357

5458
return OperationResult(success)
@@ -72,7 +76,8 @@ async def reset(self, key):
7276
""", (key,))
7377

7478
success = await cur.fetchone() is not None
75-
79+
if success:
80+
await async_write_event(cur, key, SentinelEvent.RESET)
7681
await conn.commit()
7782

7883
return OperationResult(success)

sentinel-py/sentinel/cli.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
pass
1010

1111
from .core import inspect
12+
from .events import history
1213

1314
def get_conn():
1415
db_url = os.environ.get("DATABASE_URL")
@@ -26,11 +27,15 @@ def main():
2627
inspect_parser = subparsers.add_parser("inspect", help="Inspect a lease by key")
2728
inspect_parser.add_argument("key", type=str)
2829

30+
history_parser = subparsers.add_parser("history", help="Show execution history for a key")
31+
history_parser.add_argument("key", type=str)
32+
history_parser.add_argument("--limit", type=int, default=50, help="Max events to show (default: 50)")
33+
2934
args = parser.parse_args()
3035

3136
if args.command == "inspect":
32-
conn = get_conn()
33-
result = inspect(conn, args.key)
37+
with get_conn() as conn:
38+
result = inspect(conn, args.key)
3439

3540
if result is None:
3641
print(f"No lease found for key: {args.key}")
@@ -43,6 +48,18 @@ def main():
4348
print(f"lease_updated_at: {result.lease_updated_at}")
4449
print(f"hard_expires_at: {result.hard_expires_at}")
4550
print(f"execution_result: {result.execution_result}")
51+
52+
elif args.command == "history":
53+
with get_conn() as conn:
54+
events = history(conn, args.key, limit=args.limit)
55+
56+
if not events:
57+
print(f"No history found for key: {args.key}")
58+
else:
59+
print(f"History for key: {args.key} ({len(events)} events)\n")
60+
for e in events:
61+
print(f" {e}")
62+
4663
else:
4764
parser.print_help()
4865

sentinel-py/sentinel/core.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from .utils import get_owner_id, row_to_dict
33
from .result import AcquireResult, OperationResult, InspectResult
4+
from .events import SentinelEvent, write_event
45

56
def acquire(conn, key, *, owner_id=None, ttl_ms=10000, hard_ttl_ms = None):
67

@@ -47,6 +48,7 @@ def acquire(conn, key, *, owner_id=None, ttl_ms=10000, hard_ttl_ms = None):
4748

4849
if result is not None:
4950
row = row_to_dict(cur, result)
51+
write_event(cur, key, SentinelEvent.ACQUIRED, owner_id=row["owner_id"], fencing_token=row["fencing_token"])
5052

5153
conn.commit()
5254

@@ -74,6 +76,8 @@ def acquire(conn, key, *, owner_id=None, ttl_ms=10000, hard_ttl_ms = None):
7476

7577
if result is not None:
7678
row = row_to_dict(cur, result)
79+
write_event(cur, key, SentinelEvent.REJECTED, owner_id=row["owner_id"], fencing_token=row["fencing_token"])
80+
7781

7882
conn.commit()
7983

@@ -105,6 +109,8 @@ def start_execution(conn, key, *, owner_id, fencing_token):
105109
success = result is not None
106110
if result is not None:
107111
row = row_to_dict(cur, result)
112+
write_event(cur, key, SentinelEvent.EXECUTING, owner_id=owner_id, fencing_token=fencing_token)
113+
108114

109115
conn.commit()
110116
if row is None:
@@ -121,6 +127,8 @@ def release(conn, key, *, owner_id, fencing_token):
121127
""", (key, owner_id, fencing_token))
122128

123129
success = cur.fetchone() is not None
130+
if success:
131+
write_event(cur, key, SentinelEvent.RELEASED, owner_id=owner_id, fencing_token=fencing_token)
124132

125133
conn.commit()
126134
return OperationResult(success)
@@ -147,6 +155,8 @@ def complete(conn, key, *, owner_id, fencing_token, execution_result=None):
147155
""", (serialized_result, key, owner_id, fencing_token))
148156

149157
success = cur.fetchone() is not None
158+
if success:
159+
write_event(cur, key, SentinelEvent.COMPLETED, owner_id=owner_id, fencing_token=fencing_token)
150160

151161
conn.commit()
152162
return OperationResult(success)
@@ -199,6 +209,9 @@ def expire_lease(conn, key, *, owner_id, fencing_token):
199209
RETURNING 1;
200210
""", (key, owner_id, fencing_token))
201211
success = cur.fetchone() is not None
212+
if success:
213+
write_event(cur, key, SentinelEvent.EXPIRED, owner_id=owner_id, fencing_token=fencing_token)
214+
202215
conn.commit()
203216
return OperationResult(success)
204217

0 commit comments

Comments
 (0)