postkit 0.2.0__tar.gz → 0.3.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.
- {postkit-0.2.0 → postkit-0.3.0}/PKG-INFO +2 -1
- {postkit-0.2.0 → postkit-0.3.0}/README.md +1 -0
- {postkit-0.2.0 → postkit-0.3.0}/pyproject.toml +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authz/client.py +42 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/base.py +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/config/client.py +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/errors.py +67 -0
- postkit-0.3.0/src/postkit/lease/__init__.py +17 -0
- postkit-0.3.0/src/postkit/lease/client.py +319 -0
- postkit-0.3.0/src/postkit/outbox/__init__.py +17 -0
- postkit-0.3.0/src/postkit/outbox/client.py +343 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/queue/client.py +2 -2
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_rls.py +2 -2
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_resource_hierarchy.py +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_rls.py +2 -2
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_sdk.py +21 -2
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_rls.py +3 -3
- postkit-0.3.0/tests/lease/__init__.py +0 -0
- postkit-0.3.0/tests/lease/conftest.py +102 -0
- postkit-0.3.0/tests/lease/helpers.py +113 -0
- postkit-0.3.0/tests/lease/test_acquire.py +162 -0
- postkit-0.3.0/tests/lease/test_events.py +93 -0
- postkit-0.3.0/tests/lease/test_invariants.py +149 -0
- postkit-0.3.0/tests/lease/test_maintenance.py +104 -0
- postkit-0.3.0/tests/lease/test_renew_release.py +73 -0
- postkit-0.3.0/tests/lease/test_tenancy.py +175 -0
- postkit-0.3.0/tests/lease/test_validation.py +85 -0
- postkit-0.3.0/tests/lease/test_verify_fencing.py +280 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_reservation_expiry_race.py +2 -2
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_rls.py +2 -2
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_sdk.py +1 -1
- postkit-0.3.0/tests/outbox/__init__.py +0 -0
- postkit-0.3.0/tests/outbox/conftest.py +81 -0
- postkit-0.3.0/tests/outbox/helpers.py +141 -0
- postkit-0.3.0/tests/outbox/test_emit.py +86 -0
- postkit-0.3.0/tests/outbox/test_horizon.py +208 -0
- postkit-0.3.0/tests/outbox/test_lost_cursor.py +111 -0
- postkit-0.3.0/tests/outbox/test_maintenance.py +242 -0
- postkit-0.3.0/tests/outbox/test_subscribe_poll_ack.py +173 -0
- postkit-0.3.0/tests/outbox/test_tenancy.py +120 -0
- postkit-0.3.0/tests/outbox/test_validation.py +131 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_cron.py +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_nack_ack_race.py +4 -4
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_release_jobs.py +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_rls.py +3 -3
- {postkit-0.2.0 → postkit-0.3.0}/tests/test_error_codes.py +4 -0
- {postkit-0.2.0 → postkit-0.3.0}/uv.lock +1 -1
- {postkit-0.2.0 → postkit-0.3.0}/.gitignore +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authn/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authn/client.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authz/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/config/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/meter/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/meter/client.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/py.typed +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/src/postkit/queue/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/helpers.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_audit.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_base.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_credentials.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_disabled_user.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_e2e_demo.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_impersonation.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_lockout.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_maintenance.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_operator_impersonation.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_refresh_tokens.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_sessions.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_tokens.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_users.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_validation.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/helpers.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_access_patterns.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_audit.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_concurrency.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_consistency.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_e2e_demo.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_expiration.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_groups.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_hierarchy.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_listing.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_maintenance.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_namespaces.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_nested_teams.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_stress.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_transactions.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_validation.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/helpers.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_audit.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_entries.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_schemas.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_tenancy.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_validation.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/helpers.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/integration/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/integration/test_api_keys.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/helpers.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_periods.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_validation.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/__init__.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/conftest.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/helpers.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_ack.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_dead_letters.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_e2e_demo.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_pull.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_purge.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_push.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_schedules.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_stats.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_tenancy.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_tick.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_tick_timeouts.py +0 -0
- {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: PostgreSQL-native auth, permissions, versioned config, usage tracking, and job queues
|
|
5
5
|
Project-URL: Homepage, https://github.com/varunchopra/postkit
|
|
6
6
|
Project-URL: Repository, https://github.com/varunchopra/postkit
|
|
@@ -54,6 +54,7 @@ cursor = conn.cursor()
|
|
|
54
54
|
|
|
55
55
|
# Authorization
|
|
56
56
|
authz = AuthzClient(cursor, namespace="my-app")
|
|
57
|
+
authz.set_hierarchy("repo", "admin", "write", "read")
|
|
57
58
|
authz.grant("admin", resource=("repo", "api"), subject=("user", "alice"))
|
|
58
59
|
if authz.check(("user", "alice"), "read", ("repo", "api")):
|
|
59
60
|
print("Access granted")
|
|
@@ -20,6 +20,7 @@ cursor = conn.cursor()
|
|
|
20
20
|
|
|
21
21
|
# Authorization
|
|
22
22
|
authz = AuthzClient(cursor, namespace="my-app")
|
|
23
|
+
authz.set_hierarchy("repo", "admin", "write", "read")
|
|
23
24
|
authz.grant("admin", resource=("repo", "api"), subject=("user", "alice"))
|
|
24
25
|
if authz.check(("user", "alice"), "read", ("repo", "api")):
|
|
25
26
|
print("Access granted")
|
|
@@ -9,6 +9,8 @@ This module provides:
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
12
14
|
from datetime import datetime, timedelta
|
|
13
15
|
|
|
14
16
|
import psycopg
|
|
@@ -84,6 +86,17 @@ class AuthzClient(BaseClient):
|
|
|
84
86
|
to see grants where they are the recipient across ALL namespaces.
|
|
85
87
|
Required for "Shared with me" / external resources functionality.
|
|
86
88
|
|
|
89
|
+
The setting is session-scoped. With an in-process connection pool,
|
|
90
|
+
prefer viewer_context(), which clears it however the block exits.
|
|
91
|
+
Behind PgBouncer in session pooling mode, DISCARD ALL between
|
|
92
|
+
clients also clears it; in transaction pooling mode session-scoped
|
|
93
|
+
settings are unsafe (they persist on some server connection for a
|
|
94
|
+
later client) AND unreliable (your next statement may run on a
|
|
95
|
+
different server connection), so do not use set_viewer there - set
|
|
96
|
+
the context transaction-locally instead (inside a transaction, run
|
|
97
|
+
set_config('authz.viewer_type', ..., true) and the same for
|
|
98
|
+
viewer_id).
|
|
99
|
+
|
|
87
100
|
Args:
|
|
88
101
|
subject: The subject as (type, id) tuple (e.g., ("user", "alice"))
|
|
89
102
|
|
|
@@ -116,6 +129,35 @@ class AuthzClient(BaseClient):
|
|
|
116
129
|
"set_config('authz.viewer_id', '', false)"
|
|
117
130
|
)
|
|
118
131
|
|
|
132
|
+
@contextmanager
|
|
133
|
+
def viewer_context(self, subject: Entity) -> Iterator[None]:
|
|
134
|
+
"""Set the viewer context for the duration of a block.
|
|
135
|
+
|
|
136
|
+
Equivalent to set_viewer() followed by a guaranteed clear_viewer(),
|
|
137
|
+
so the session-scoped viewer identity cannot leak to the next user
|
|
138
|
+
of a pooled connection, whichever way the block exits. Exiting
|
|
139
|
+
clears the viewer entirely; it does not restore one that was set
|
|
140
|
+
before the block.
|
|
141
|
+
|
|
142
|
+
If the connection died inside the block, the clearing call raises
|
|
143
|
+
too; the original exception is chained as its __context__, and a
|
|
144
|
+
dead session cannot leak, so nothing is suppressed here.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
subject: The subject as (type, id) tuple (e.g., ("user", "alice"))
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
with authz.viewer_context(("user", "alice")):
|
|
151
|
+
shared = authz.list_external_resources(
|
|
152
|
+
("user", "alice"), "note", "view"
|
|
153
|
+
)
|
|
154
|
+
"""
|
|
155
|
+
self.set_viewer(subject)
|
|
156
|
+
try:
|
|
157
|
+
yield
|
|
158
|
+
finally:
|
|
159
|
+
self.clear_viewer()
|
|
160
|
+
|
|
119
161
|
def _apply_actor_context(self) -> None:
|
|
120
162
|
"""Apply actor context via authz.set_actor()."""
|
|
121
163
|
self.cursor.execute(
|
|
@@ -200,7 +200,7 @@ class BaseClient(ABC):
|
|
|
200
200
|
context automatically clears on commit, preventing cross-tenant
|
|
201
201
|
leakage in connection pools.
|
|
202
202
|
|
|
203
|
-
Safe to call repeatedly within the same transaction
|
|
203
|
+
Safe to call repeatedly within the same transaction - set_config
|
|
204
204
|
with the same value is a no-op at the PostgreSQL level.
|
|
205
205
|
"""
|
|
206
206
|
try:
|
|
@@ -41,7 +41,7 @@ class ConfigClient(BaseClient):
|
|
|
41
41
|
"""Client for Postkit config module.
|
|
42
42
|
|
|
43
43
|
Manages versioned configuration including prompts, feature flags, secrets,
|
|
44
|
-
and settings. All config types use the same API
|
|
44
|
+
and settings. All config types use the same API - differentiate by key
|
|
45
45
|
naming conventions.
|
|
46
46
|
|
|
47
47
|
Example:
|
|
@@ -142,6 +142,38 @@ class ConfigErrorCode:
|
|
|
142
142
|
BIZ_DELETE_ACTIVE_VERSION = "BIZ_DELETE_ACTIVE_VERSION"
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
class LeaseErrorCode:
|
|
146
|
+
"""Error codes for the lease module."""
|
|
147
|
+
|
|
148
|
+
# Validation errors (VAL_)
|
|
149
|
+
VAL_NAMESPACE_NULL = "VAL_NAMESPACE_NULL"
|
|
150
|
+
VAL_NAMESPACE_EMPTY = "VAL_NAMESPACE_EMPTY"
|
|
151
|
+
VAL_NAMESPACE_TOO_LONG = "VAL_NAMESPACE_TOO_LONG"
|
|
152
|
+
VAL_NAMESPACE_INVALID_CHARS = "VAL_NAMESPACE_INVALID_CHARS"
|
|
153
|
+
VAL_NAMESPACE_WHITESPACE = "VAL_NAMESPACE_WHITESPACE"
|
|
154
|
+
VAL_LEASE_NAME_NULL = "VAL_LEASE_NAME_NULL"
|
|
155
|
+
VAL_LEASE_NAME_EMPTY = "VAL_LEASE_NAME_EMPTY"
|
|
156
|
+
VAL_LEASE_NAME_TOO_LONG = "VAL_LEASE_NAME_TOO_LONG"
|
|
157
|
+
VAL_LEASE_NAME_INVALID_CHARS = "VAL_LEASE_NAME_INVALID_CHARS"
|
|
158
|
+
VAL_LEASE_NAME_WHITESPACE = "VAL_LEASE_NAME_WHITESPACE"
|
|
159
|
+
VAL_HOLDER_NULL = "VAL_HOLDER_NULL"
|
|
160
|
+
VAL_HOLDER_EMPTY = "VAL_HOLDER_EMPTY"
|
|
161
|
+
VAL_HOLDER_TOO_LONG = "VAL_HOLDER_TOO_LONG"
|
|
162
|
+
VAL_HOLDER_INVALID_CHARS = "VAL_HOLDER_INVALID_CHARS"
|
|
163
|
+
VAL_HOLDER_WHITESPACE = "VAL_HOLDER_WHITESPACE"
|
|
164
|
+
VAL_TTL_NOT_POSITIVE = "VAL_TTL_NOT_POSITIVE"
|
|
165
|
+
VAL_TTL_EXCEEDS_MAX = "VAL_TTL_EXCEEDS_MAX"
|
|
166
|
+
VAL_FENCE_NULL = "VAL_FENCE_NULL"
|
|
167
|
+
VAL_NOT_POSITIVE = "VAL_NOT_POSITIVE"
|
|
168
|
+
VAL_PRUNE_INTERVAL_NOT_POSITIVE = "VAL_PRUNE_INTERVAL_NOT_POSITIVE"
|
|
169
|
+
|
|
170
|
+
# Fencing (raised with SQLSTATE 40001, serialization_failure)
|
|
171
|
+
FENCE_STALE = "FENCE_STALE"
|
|
172
|
+
|
|
173
|
+
# Internal errors (INT_)
|
|
174
|
+
INT_COUNTER_MISSING = "INT_COUNTER_MISSING"
|
|
175
|
+
|
|
176
|
+
|
|
145
177
|
class MeterErrorCode:
|
|
146
178
|
"""Error codes for the meter module."""
|
|
147
179
|
|
|
@@ -173,6 +205,41 @@ class MeterErrorCode:
|
|
|
173
205
|
DATA_ACCOUNT_NOT_FOUND = "DATA_ACCOUNT_NOT_FOUND"
|
|
174
206
|
|
|
175
207
|
|
|
208
|
+
class OutboxErrorCode:
|
|
209
|
+
"""Error codes for the outbox module."""
|
|
210
|
+
|
|
211
|
+
# Validation errors (VAL_)
|
|
212
|
+
VAL_NAMESPACE_NULL = "VAL_NAMESPACE_NULL"
|
|
213
|
+
VAL_NAMESPACE_EMPTY = "VAL_NAMESPACE_EMPTY"
|
|
214
|
+
VAL_NAMESPACE_TOO_LONG = "VAL_NAMESPACE_TOO_LONG"
|
|
215
|
+
VAL_NAMESPACE_INVALID_CHARS = "VAL_NAMESPACE_INVALID_CHARS"
|
|
216
|
+
VAL_NAMESPACE_WHITESPACE = "VAL_NAMESPACE_WHITESPACE"
|
|
217
|
+
VAL_TOPIC_NULL = "VAL_TOPIC_NULL"
|
|
218
|
+
VAL_TOPIC_EMPTY = "VAL_TOPIC_EMPTY"
|
|
219
|
+
VAL_TOPIC_TOO_LONG = "VAL_TOPIC_TOO_LONG"
|
|
220
|
+
VAL_TOPIC_RESERVED = "VAL_TOPIC_RESERVED"
|
|
221
|
+
VAL_TOPIC_FORMAT = "VAL_TOPIC_FORMAT"
|
|
222
|
+
VAL_CONSUMER_NULL = "VAL_CONSUMER_NULL"
|
|
223
|
+
VAL_CONSUMER_EMPTY = "VAL_CONSUMER_EMPTY"
|
|
224
|
+
VAL_CONSUMER_TOO_LONG = "VAL_CONSUMER_TOO_LONG"
|
|
225
|
+
VAL_CONSUMER_FORMAT = "VAL_CONSUMER_FORMAT"
|
|
226
|
+
VAL_EVENT_TYPE_NULL = "VAL_EVENT_TYPE_NULL"
|
|
227
|
+
VAL_EVENT_TYPE_EMPTY = "VAL_EVENT_TYPE_EMPTY"
|
|
228
|
+
VAL_EVENT_TYPE_TOO_LONG = "VAL_EVENT_TYPE_TOO_LONG"
|
|
229
|
+
VAL_EVENT_TYPE_FORMAT = "VAL_EVENT_TYPE_FORMAT"
|
|
230
|
+
VAL_PAYLOAD_NULL = "VAL_PAYLOAD_NULL"
|
|
231
|
+
VAL_NOT_POSITIVE = "VAL_NOT_POSITIVE"
|
|
232
|
+
VAL_POSITION_INVALID = "VAL_POSITION_INVALID"
|
|
233
|
+
VAL_SUBSCRIBE_FROM_REQUIRED = "VAL_SUBSCRIBE_FROM_REQUIRED"
|
|
234
|
+
VAL_TRIM_INTERVAL_NOT_POSITIVE = "VAL_TRIM_INTERVAL_NOT_POSITIVE"
|
|
235
|
+
|
|
236
|
+
# Business logic errors (BIZ_)
|
|
237
|
+
BIZ_CONSUMER_EXISTS = "BIZ_CONSUMER_EXISTS"
|
|
238
|
+
BIZ_CONSUMER_UNKNOWN = "BIZ_CONSUMER_UNKNOWN"
|
|
239
|
+
BIZ_CURSOR_LOST = "BIZ_CURSOR_LOST"
|
|
240
|
+
BIZ_POSITION_BEYOND_HEAD = "BIZ_POSITION_BEYOND_HEAD"
|
|
241
|
+
|
|
242
|
+
|
|
176
243
|
class QueueErrorCode:
|
|
177
244
|
"""Error codes for the queue module."""
|
|
178
245
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Postkit Lease SDK - Postgres-native leases with fencing tokens."""
|
|
2
|
+
|
|
3
|
+
from postkit.errors import LeaseErrorCode
|
|
4
|
+
from postkit.lease.client import (
|
|
5
|
+
LeaseClient,
|
|
6
|
+
LeaseError,
|
|
7
|
+
LeaseFencingError,
|
|
8
|
+
LeaseValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"LeaseClient",
|
|
13
|
+
"LeaseError",
|
|
14
|
+
"LeaseErrorCode",
|
|
15
|
+
"LeaseFencingError",
|
|
16
|
+
"LeaseValidationError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Postkit Lease SDK - Postgres-native leases with fencing tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any, NoReturn
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
|
|
11
|
+
from postkit.base import BaseClient, PostkitError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LeaseError(PostkitError):
|
|
15
|
+
"""Exception for lease operations."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LeaseValidationError(LeaseError):
|
|
19
|
+
"""Raised when input validation fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LeaseFencingError(LeaseError):
|
|
23
|
+
"""Raised by verify() when the lease is lost, expired, or taken over.
|
|
24
|
+
|
|
25
|
+
The fence is dead: the correct recovery is RE-ACQUIRE, THEN REDO the
|
|
26
|
+
work – never replay the same transaction with the same fence (the replay
|
|
27
|
+
fails deterministically, so a naive retry loop spins).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LeaseClient(BaseClient):
|
|
32
|
+
"""Client for Postkit lease module.
|
|
33
|
+
|
|
34
|
+
TTL-based named locks with fencing tokens. Call verify() inside the same
|
|
35
|
+
transaction as the writes the lease protects – the fence check and the
|
|
36
|
+
protected writes then commit or abort together.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
lease = LeaseClient(cursor, namespace="acme")
|
|
40
|
+
|
|
41
|
+
got = lease.acquire("scheduler", holder="worker-1")
|
|
42
|
+
if got["acquired"]:
|
|
43
|
+
fence = got["fence_token"]
|
|
44
|
+
# ... inside the protected transaction:
|
|
45
|
+
lease.verify("scheduler", "worker-1", fence) # raises if lost
|
|
46
|
+
# ... protected writes ...
|
|
47
|
+
lease.release("scheduler", "worker-1", fence)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_schema = "lease"
|
|
51
|
+
_error_class = LeaseError
|
|
52
|
+
_module_sqlstate_map = {
|
|
53
|
+
"22023": LeaseValidationError, # invalid_parameter_value
|
|
54
|
+
"22004": LeaseValidationError, # null_value_not_allowed
|
|
55
|
+
"22001": LeaseValidationError, # string_data_right_truncation
|
|
56
|
+
"22026": LeaseValidationError, # string_data_length_mismatch
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def _handle_error(self, e: psycopg.Error) -> NoReturn:
|
|
60
|
+
# 40001 is a SHARED sqlstate: FOR SHARE under REPEATABLE READ can
|
|
61
|
+
# raise a genuine serialization failure inside any lease call. Only
|
|
62
|
+
# our hint makes it a fencing error; everything else must surface as
|
|
63
|
+
# the retryable serialization error it is (generic LeaseError,
|
|
64
|
+
# .sqlstate intact).
|
|
65
|
+
sqlstate = getattr(e, "sqlstate", None)
|
|
66
|
+
hint = e.diag.message_hint if hasattr(e, "diag") and e.diag else None
|
|
67
|
+
if sqlstate == "40001" and hint == "postkit:lease:FENCE_STALE":
|
|
68
|
+
raise LeaseFencingError(str(e), sqlstate, hint) from e
|
|
69
|
+
super()._handle_error(e)
|
|
70
|
+
|
|
71
|
+
def _apply_actor_context(self) -> None:
|
|
72
|
+
"""Apply actor context via lease.set_actor()."""
|
|
73
|
+
self.cursor.execute(
|
|
74
|
+
"""SELECT lease.set_actor(
|
|
75
|
+
p_actor_id := %s,
|
|
76
|
+
p_request_id := %s,
|
|
77
|
+
p_on_behalf_of := %s,
|
|
78
|
+
p_reason := %s
|
|
79
|
+
)""",
|
|
80
|
+
(self._actor_id, self._request_id, self._on_behalf_of, self._reason),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def acquire(
|
|
84
|
+
self,
|
|
85
|
+
name: str,
|
|
86
|
+
holder: str,
|
|
87
|
+
*,
|
|
88
|
+
ttl: timedelta | None = None,
|
|
89
|
+
metadata: dict[str, Any] | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Acquire or take over a named lease.
|
|
92
|
+
|
|
93
|
+
A free or expired lease makes the caller the holder with a new fence
|
|
94
|
+
token (takeovers are event-logged). Re-acquiring a lease you already
|
|
95
|
+
hold live extends it with the SAME fence; passing metadata replaces
|
|
96
|
+
the stored value, passing None keeps it. A lease held live by
|
|
97
|
+
someone else is not touched.
|
|
98
|
+
|
|
99
|
+
Do not call verify() then acquire() on the same name inside one
|
|
100
|
+
transaction: under concurrency that can abort as a deadlock
|
|
101
|
+
(SQLSTATE 40P01, retryable).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
name: Lease name (e.g. 'scheduler', 'exporter:cust_42')
|
|
105
|
+
holder: Opaque holder identity (hostname, pod name, worker ID)
|
|
106
|
+
ttl: Lease duration (default from config; capped at max_ttl)
|
|
107
|
+
metadata: Metadata stored on the lease; None keeps the existing
|
|
108
|
+
metadata on a live re-acquire (new acquisitions start empty)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dict with acquired (bool), fence_token (None when held by
|
|
112
|
+
another live holder), expires_at, and current_holder (all
|
|
113
|
+
None on a lock timeout - an ordinary contended miss).
|
|
114
|
+
"""
|
|
115
|
+
result = self._fetch_one(
|
|
116
|
+
"""SELECT * FROM lease.acquire(
|
|
117
|
+
p_namespace := %s,
|
|
118
|
+
p_name := %s,
|
|
119
|
+
p_holder := %s,
|
|
120
|
+
p_ttl := %s,
|
|
121
|
+
p_metadata := %s::jsonb
|
|
122
|
+
)""",
|
|
123
|
+
(
|
|
124
|
+
self.namespace,
|
|
125
|
+
name,
|
|
126
|
+
holder,
|
|
127
|
+
ttl,
|
|
128
|
+
json.dumps(metadata) if metadata is not None else None,
|
|
129
|
+
),
|
|
130
|
+
write=True,
|
|
131
|
+
)
|
|
132
|
+
if result is None:
|
|
133
|
+
raise LeaseError("lease.acquire returned no row")
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def renew(
|
|
137
|
+
self,
|
|
138
|
+
name: str,
|
|
139
|
+
holder: str,
|
|
140
|
+
fence: int,
|
|
141
|
+
*,
|
|
142
|
+
ttl: timedelta | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Extend a live lease you hold.
|
|
145
|
+
|
|
146
|
+
Fails (renewed=False) once the lease is past its expiry – even if
|
|
147
|
+
nobody else has taken it. Re-acquire to continue, receiving a new
|
|
148
|
+
fence. Does not update metadata (acquire's same-holder path does).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: Lease name
|
|
152
|
+
holder: Holder identity (must match the lease)
|
|
153
|
+
fence: Fence token from acquire (must match the lease)
|
|
154
|
+
ttl: New duration from now (default from config; capped at max_ttl)
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Dict with renewed (bool) and expires_at (None when not renewed)
|
|
158
|
+
"""
|
|
159
|
+
result = self._fetch_one(
|
|
160
|
+
"""SELECT * FROM lease.renew(
|
|
161
|
+
p_namespace := %s,
|
|
162
|
+
p_name := %s,
|
|
163
|
+
p_holder := %s,
|
|
164
|
+
p_fence := %s,
|
|
165
|
+
p_ttl := %s
|
|
166
|
+
)""",
|
|
167
|
+
(self.namespace, name, holder, fence, ttl),
|
|
168
|
+
write=True,
|
|
169
|
+
)
|
|
170
|
+
if result is None:
|
|
171
|
+
raise LeaseError("lease.renew returned no row")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
def release(self, name: str, holder: str, fence: int) -> bool:
|
|
175
|
+
"""Release a lease you hold.
|
|
176
|
+
|
|
177
|
+
Idempotent: releasing a lease you no longer hold returns False,
|
|
178
|
+
never raises.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
name: Lease name
|
|
182
|
+
holder: Holder identity (must match the lease)
|
|
183
|
+
fence: Fence token from acquire (must match the lease)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if released, False if not held with this holder and fence
|
|
187
|
+
"""
|
|
188
|
+
result = self._fetch_val(
|
|
189
|
+
"SELECT lease.release(%s, %s, %s, %s)",
|
|
190
|
+
(self.namespace, name, holder, fence),
|
|
191
|
+
write=True,
|
|
192
|
+
)
|
|
193
|
+
return bool(result)
|
|
194
|
+
|
|
195
|
+
def verify(self, name: str, holder: str, fence: int) -> None:
|
|
196
|
+
"""Assert, inside your transaction, that you still hold a lease.
|
|
197
|
+
|
|
198
|
+
Must be called inside the same open transaction as the writes the
|
|
199
|
+
lease protects; the check and the writes then commit or abort
|
|
200
|
+
together. Without one (autocommit, or between transactions) the
|
|
201
|
+
fence lock would be released immediately and protect nothing, so
|
|
202
|
+
this method refuses to run.
|
|
203
|
+
|
|
204
|
+
Do not call verify() then acquire() on the same name inside one
|
|
205
|
+
transaction: under concurrency that can abort as a deadlock
|
|
206
|
+
(SQLSTATE 40P01, retryable).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
name: Lease name
|
|
210
|
+
holder: Holder identity (must match the lease)
|
|
211
|
+
fence: Fence token from acquire (must match the lease)
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
LeaseError: No transaction is open on the connection.
|
|
215
|
+
LeaseFencingError: The lease is lost, expired, or taken over.
|
|
216
|
+
Re-acquire, then redo – never replay with the same fence.
|
|
217
|
+
"""
|
|
218
|
+
# Same check _with_context uses: without an open transaction it
|
|
219
|
+
# would wrap verify in its own transaction that commits at return,
|
|
220
|
+
# releasing the FOR SHARE lock and silently voiding the guarantee.
|
|
221
|
+
if self.cursor.connection.info.transaction_status == 0:
|
|
222
|
+
raise LeaseError(
|
|
223
|
+
"verify() requires an open transaction: without one the "
|
|
224
|
+
"fence lock is released immediately and protects nothing. "
|
|
225
|
+
"Open a transaction around verify() and the writes it "
|
|
226
|
+
"protects."
|
|
227
|
+
)
|
|
228
|
+
self._fetch_val(
|
|
229
|
+
"SELECT lease.verify(%s, %s, %s, %s)",
|
|
230
|
+
(self.namespace, name, holder, fence),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def current(self, name: str) -> dict[str, Any] | None:
|
|
234
|
+
"""Inspect a lease without locking it.
|
|
235
|
+
|
|
236
|
+
Returns the row even when past its expiry (compare expires_at to
|
|
237
|
+
judge liveness); use verify() for a fenced check.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
name: Lease name
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dict with holder_id, fence_token, expires_at, metadata,
|
|
244
|
+
or None when no lease row exists
|
|
245
|
+
"""
|
|
246
|
+
return self._fetch_one(
|
|
247
|
+
"SELECT * FROM lease.current(%s, %s)", (self.namespace, name)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def get_stats(self) -> dict[str, Any]:
|
|
251
|
+
"""Get namespace-wide lease statistics.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Dict with total_leases, live, expired, total_names (every lease
|
|
255
|
+
name ever used), and total_events counts
|
|
256
|
+
"""
|
|
257
|
+
result = self._fetch_one(
|
|
258
|
+
"SELECT * FROM lease.get_stats(%s)",
|
|
259
|
+
(self.namespace,),
|
|
260
|
+
)
|
|
261
|
+
return result or {}
|
|
262
|
+
|
|
263
|
+
def get_events(
|
|
264
|
+
self, name: str | None = None, *, limit: int = 100
|
|
265
|
+
) -> list[dict[str, Any]]:
|
|
266
|
+
"""Read the lease event log, newest first.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
name: Lease name filter (None = all names)
|
|
270
|
+
limit: Maximum events to return
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List of event dicts (acquired, released, taken_over) with
|
|
274
|
+
actor context
|
|
275
|
+
"""
|
|
276
|
+
return self._fetch_all(
|
|
277
|
+
"SELECT * FROM lease.get_events(%s, %s, %s)",
|
|
278
|
+
(self.namespace, name, limit),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def prune_events(
|
|
282
|
+
self, older_than: timedelta, name: str | None = None, *, limit: int = 10000
|
|
283
|
+
) -> int:
|
|
284
|
+
"""Delete old lease events.
|
|
285
|
+
|
|
286
|
+
The event log is the module's audit surface, so retention has no
|
|
287
|
+
default – pass it explicitly and call this from a maintenance loop.
|
|
288
|
+
Each call deletes at most `limit` events; call repeatedly until the
|
|
289
|
+
return value is below the limit.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
older_than: Delete events older than this (required, positive)
|
|
293
|
+
name: Lease name filter (None = all names)
|
|
294
|
+
limit: Maximum events to delete per call
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Count of deleted events
|
|
298
|
+
"""
|
|
299
|
+
result = self._fetch_val(
|
|
300
|
+
"SELECT lease.prune_events(%s, %s, %s, %s)",
|
|
301
|
+
(self.namespace, older_than, name, limit),
|
|
302
|
+
write=True,
|
|
303
|
+
)
|
|
304
|
+
if result is None:
|
|
305
|
+
raise LeaseError("lease.prune_events returned no value")
|
|
306
|
+
return int(result)
|
|
307
|
+
|
|
308
|
+
def list_leases(self, *, include_expired: bool = True) -> list[dict[str, Any]]:
|
|
309
|
+
"""List leases in the namespace, most recently acquired first.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
include_expired: Include rows past their expiry (default True)
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
List of lease row dicts
|
|
316
|
+
"""
|
|
317
|
+
return self._fetch_all(
|
|
318
|
+
"SELECT * FROM lease.list(%s, %s)", (self.namespace, include_expired)
|
|
319
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Postkit Outbox SDK - transactional event feed with durable cursors."""
|
|
2
|
+
|
|
3
|
+
from postkit.errors import OutboxErrorCode
|
|
4
|
+
from postkit.outbox.client import (
|
|
5
|
+
OutboxClient,
|
|
6
|
+
OutboxCursorLostError,
|
|
7
|
+
OutboxError,
|
|
8
|
+
OutboxValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"OutboxClient",
|
|
13
|
+
"OutboxCursorLostError",
|
|
14
|
+
"OutboxError",
|
|
15
|
+
"OutboxErrorCode",
|
|
16
|
+
"OutboxValidationError",
|
|
17
|
+
]
|