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.
Files changed (124) hide show
  1. {postkit-0.2.0 → postkit-0.3.0}/PKG-INFO +2 -1
  2. {postkit-0.2.0 → postkit-0.3.0}/README.md +1 -0
  3. {postkit-0.2.0 → postkit-0.3.0}/pyproject.toml +1 -1
  4. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authz/client.py +42 -0
  5. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/base.py +1 -1
  6. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/config/client.py +1 -1
  7. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/errors.py +67 -0
  8. postkit-0.3.0/src/postkit/lease/__init__.py +17 -0
  9. postkit-0.3.0/src/postkit/lease/client.py +319 -0
  10. postkit-0.3.0/src/postkit/outbox/__init__.py +17 -0
  11. postkit-0.3.0/src/postkit/outbox/client.py +343 -0
  12. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/queue/client.py +2 -2
  13. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_rls.py +2 -2
  14. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_resource_hierarchy.py +1 -1
  15. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_rls.py +2 -2
  16. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_sdk.py +21 -2
  17. {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_rls.py +3 -3
  18. postkit-0.3.0/tests/lease/__init__.py +0 -0
  19. postkit-0.3.0/tests/lease/conftest.py +102 -0
  20. postkit-0.3.0/tests/lease/helpers.py +113 -0
  21. postkit-0.3.0/tests/lease/test_acquire.py +162 -0
  22. postkit-0.3.0/tests/lease/test_events.py +93 -0
  23. postkit-0.3.0/tests/lease/test_invariants.py +149 -0
  24. postkit-0.3.0/tests/lease/test_maintenance.py +104 -0
  25. postkit-0.3.0/tests/lease/test_renew_release.py +73 -0
  26. postkit-0.3.0/tests/lease/test_tenancy.py +175 -0
  27. postkit-0.3.0/tests/lease/test_validation.py +85 -0
  28. postkit-0.3.0/tests/lease/test_verify_fencing.py +280 -0
  29. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_reservation_expiry_race.py +2 -2
  30. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_rls.py +2 -2
  31. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_sdk.py +1 -1
  32. postkit-0.3.0/tests/outbox/__init__.py +0 -0
  33. postkit-0.3.0/tests/outbox/conftest.py +81 -0
  34. postkit-0.3.0/tests/outbox/helpers.py +141 -0
  35. postkit-0.3.0/tests/outbox/test_emit.py +86 -0
  36. postkit-0.3.0/tests/outbox/test_horizon.py +208 -0
  37. postkit-0.3.0/tests/outbox/test_lost_cursor.py +111 -0
  38. postkit-0.3.0/tests/outbox/test_maintenance.py +242 -0
  39. postkit-0.3.0/tests/outbox/test_subscribe_poll_ack.py +173 -0
  40. postkit-0.3.0/tests/outbox/test_tenancy.py +120 -0
  41. postkit-0.3.0/tests/outbox/test_validation.py +131 -0
  42. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_cron.py +1 -1
  43. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_nack_ack_race.py +4 -4
  44. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_release_jobs.py +1 -1
  45. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_rls.py +3 -3
  46. {postkit-0.2.0 → postkit-0.3.0}/tests/test_error_codes.py +4 -0
  47. {postkit-0.2.0 → postkit-0.3.0}/uv.lock +1 -1
  48. {postkit-0.2.0 → postkit-0.3.0}/.gitignore +0 -0
  49. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/__init__.py +0 -0
  50. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authn/__init__.py +0 -0
  51. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authn/client.py +0 -0
  52. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/authz/__init__.py +0 -0
  53. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/config/__init__.py +0 -0
  54. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/meter/__init__.py +0 -0
  55. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/meter/client.py +0 -0
  56. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/py.typed +0 -0
  57. {postkit-0.2.0 → postkit-0.3.0}/src/postkit/queue/__init__.py +0 -0
  58. {postkit-0.2.0 → postkit-0.3.0}/tests/__init__.py +0 -0
  59. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/__init__.py +0 -0
  60. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/conftest.py +0 -0
  61. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/helpers.py +0 -0
  62. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_audit.py +0 -0
  63. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_base.py +0 -0
  64. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_credentials.py +0 -0
  65. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_disabled_user.py +0 -0
  66. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_e2e_demo.py +0 -0
  67. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_impersonation.py +0 -0
  68. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_lockout.py +0 -0
  69. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_maintenance.py +0 -0
  70. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_operator_impersonation.py +0 -0
  71. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_refresh_tokens.py +0 -0
  72. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_sessions.py +0 -0
  73. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_tokens.py +0 -0
  74. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_users.py +0 -0
  75. {postkit-0.2.0 → postkit-0.3.0}/tests/authn/test_validation.py +0 -0
  76. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/__init__.py +0 -0
  77. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/conftest.py +0 -0
  78. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/helpers.py +0 -0
  79. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_access_patterns.py +0 -0
  80. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_audit.py +0 -0
  81. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_concurrency.py +0 -0
  82. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_consistency.py +0 -0
  83. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_e2e_demo.py +0 -0
  84. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_expiration.py +0 -0
  85. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_groups.py +0 -0
  86. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_hierarchy.py +0 -0
  87. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_listing.py +0 -0
  88. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_maintenance.py +0 -0
  89. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_namespaces.py +0 -0
  90. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_nested_teams.py +0 -0
  91. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_stress.py +0 -0
  92. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_transactions.py +0 -0
  93. {postkit-0.2.0 → postkit-0.3.0}/tests/authz/test_validation.py +0 -0
  94. {postkit-0.2.0 → postkit-0.3.0}/tests/config/__init__.py +0 -0
  95. {postkit-0.2.0 → postkit-0.3.0}/tests/config/conftest.py +0 -0
  96. {postkit-0.2.0 → postkit-0.3.0}/tests/config/helpers.py +0 -0
  97. {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_audit.py +0 -0
  98. {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_entries.py +0 -0
  99. {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_schemas.py +0 -0
  100. {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_tenancy.py +0 -0
  101. {postkit-0.2.0 → postkit-0.3.0}/tests/config/test_validation.py +0 -0
  102. {postkit-0.2.0 → postkit-0.3.0}/tests/conftest.py +0 -0
  103. {postkit-0.2.0 → postkit-0.3.0}/tests/helpers.py +0 -0
  104. {postkit-0.2.0 → postkit-0.3.0}/tests/integration/conftest.py +0 -0
  105. {postkit-0.2.0 → postkit-0.3.0}/tests/integration/test_api_keys.py +0 -0
  106. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/conftest.py +0 -0
  107. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/helpers.py +0 -0
  108. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_periods.py +0 -0
  109. {postkit-0.2.0 → postkit-0.3.0}/tests/meter/test_validation.py +0 -0
  110. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/__init__.py +0 -0
  111. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/conftest.py +0 -0
  112. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/helpers.py +0 -0
  113. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_ack.py +0 -0
  114. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_dead_letters.py +0 -0
  115. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_e2e_demo.py +0 -0
  116. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_pull.py +0 -0
  117. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_purge.py +0 -0
  118. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_push.py +0 -0
  119. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_schedules.py +0 -0
  120. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_stats.py +0 -0
  121. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_tenancy.py +0 -0
  122. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_tick.py +0 -0
  123. {postkit-0.2.0 → postkit-0.3.0}/tests/queue/test_tick_timeouts.py +0 -0
  124. {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.2.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")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "postkit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "PostgreSQL-native auth, permissions, versioned config, usage tracking, and job queues"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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 set_config
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 differentiate by key
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
+ ]