postkit 0.3.0__tar.gz → 0.4.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 (138) hide show
  1. {postkit-0.3.0 → postkit-0.4.0}/PKG-INFO +1 -1
  2. {postkit-0.3.0 → postkit-0.4.0}/pyproject.toml +1 -1
  3. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/errors.py +29 -0
  4. postkit-0.4.0/src/postkit/presence/__init__.py +15 -0
  5. postkit-0.4.0/src/postkit/presence/client.py +274 -0
  6. postkit-0.4.0/tests/integration/test_presence_hooks.py +189 -0
  7. postkit-0.4.0/tests/presence/__init__.py +0 -0
  8. postkit-0.4.0/tests/presence/conftest.py +81 -0
  9. postkit-0.4.0/tests/presence/helpers.py +88 -0
  10. postkit-0.4.0/tests/presence/test_flap.py +146 -0
  11. postkit-0.4.0/tests/presence/test_heartbeat.py +102 -0
  12. postkit-0.4.0/tests/presence/test_invariants.py +189 -0
  13. postkit-0.4.0/tests/presence/test_maintenance.py +98 -0
  14. postkit-0.4.0/tests/presence/test_state_machine.py +177 -0
  15. postkit-0.4.0/tests/presence/test_tenancy.py +108 -0
  16. postkit-0.4.0/tests/presence/test_validation.py +100 -0
  17. postkit-0.4.0/tests/presence/test_wall_clock.py +56 -0
  18. {postkit-0.3.0 → postkit-0.4.0}/tests/test_error_codes.py +2 -0
  19. {postkit-0.3.0 → postkit-0.4.0}/uv.lock +1 -1
  20. {postkit-0.3.0 → postkit-0.4.0}/.gitignore +0 -0
  21. {postkit-0.3.0 → postkit-0.4.0}/README.md +0 -0
  22. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/__init__.py +0 -0
  23. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authn/__init__.py +0 -0
  24. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authn/client.py +0 -0
  25. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authz/__init__.py +0 -0
  26. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authz/client.py +0 -0
  27. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/base.py +0 -0
  28. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/config/__init__.py +0 -0
  29. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/config/client.py +0 -0
  30. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/lease/__init__.py +0 -0
  31. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/lease/client.py +0 -0
  32. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/meter/__init__.py +0 -0
  33. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/meter/client.py +0 -0
  34. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/outbox/__init__.py +0 -0
  35. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/outbox/client.py +0 -0
  36. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/py.typed +0 -0
  37. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/queue/__init__.py +0 -0
  38. {postkit-0.3.0 → postkit-0.4.0}/src/postkit/queue/client.py +0 -0
  39. {postkit-0.3.0 → postkit-0.4.0}/tests/__init__.py +0 -0
  40. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/__init__.py +0 -0
  41. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/conftest.py +0 -0
  42. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/helpers.py +0 -0
  43. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_audit.py +0 -0
  44. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_base.py +0 -0
  45. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_credentials.py +0 -0
  46. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_disabled_user.py +0 -0
  47. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_e2e_demo.py +0 -0
  48. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_impersonation.py +0 -0
  49. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_lockout.py +0 -0
  50. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_maintenance.py +0 -0
  51. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_operator_impersonation.py +0 -0
  52. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_refresh_tokens.py +0 -0
  53. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_rls.py +0 -0
  54. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_sessions.py +0 -0
  55. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_tokens.py +0 -0
  56. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_users.py +0 -0
  57. {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_validation.py +0 -0
  58. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/__init__.py +0 -0
  59. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/conftest.py +0 -0
  60. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/helpers.py +0 -0
  61. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_access_patterns.py +0 -0
  62. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_audit.py +0 -0
  63. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_concurrency.py +0 -0
  64. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_consistency.py +0 -0
  65. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_e2e_demo.py +0 -0
  66. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_expiration.py +0 -0
  67. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_groups.py +0 -0
  68. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_hierarchy.py +0 -0
  69. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_listing.py +0 -0
  70. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_maintenance.py +0 -0
  71. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_namespaces.py +0 -0
  72. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_nested_teams.py +0 -0
  73. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_resource_hierarchy.py +0 -0
  74. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_rls.py +0 -0
  75. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_sdk.py +0 -0
  76. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_stress.py +0 -0
  77. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_transactions.py +0 -0
  78. {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_validation.py +0 -0
  79. {postkit-0.3.0 → postkit-0.4.0}/tests/config/__init__.py +0 -0
  80. {postkit-0.3.0 → postkit-0.4.0}/tests/config/conftest.py +0 -0
  81. {postkit-0.3.0 → postkit-0.4.0}/tests/config/helpers.py +0 -0
  82. {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_audit.py +0 -0
  83. {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_entries.py +0 -0
  84. {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_rls.py +0 -0
  85. {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_schemas.py +0 -0
  86. {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_tenancy.py +0 -0
  87. {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_validation.py +0 -0
  88. {postkit-0.3.0 → postkit-0.4.0}/tests/conftest.py +0 -0
  89. {postkit-0.3.0 → postkit-0.4.0}/tests/helpers.py +0 -0
  90. {postkit-0.3.0 → postkit-0.4.0}/tests/integration/conftest.py +0 -0
  91. {postkit-0.3.0 → postkit-0.4.0}/tests/integration/test_api_keys.py +0 -0
  92. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/__init__.py +0 -0
  93. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/conftest.py +0 -0
  94. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/helpers.py +0 -0
  95. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_acquire.py +0 -0
  96. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_events.py +0 -0
  97. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_invariants.py +0 -0
  98. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_maintenance.py +0 -0
  99. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_renew_release.py +0 -0
  100. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_tenancy.py +0 -0
  101. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_validation.py +0 -0
  102. {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_verify_fencing.py +0 -0
  103. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/conftest.py +0 -0
  104. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/helpers.py +0 -0
  105. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_periods.py +0 -0
  106. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_reservation_expiry_race.py +0 -0
  107. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_rls.py +0 -0
  108. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_sdk.py +0 -0
  109. {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_validation.py +0 -0
  110. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/__init__.py +0 -0
  111. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/conftest.py +0 -0
  112. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/helpers.py +0 -0
  113. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_emit.py +0 -0
  114. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_horizon.py +0 -0
  115. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_lost_cursor.py +0 -0
  116. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_maintenance.py +0 -0
  117. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_subscribe_poll_ack.py +0 -0
  118. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_tenancy.py +0 -0
  119. {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_validation.py +0 -0
  120. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/__init__.py +0 -0
  121. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/conftest.py +0 -0
  122. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/helpers.py +0 -0
  123. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_ack.py +0 -0
  124. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_cron.py +0 -0
  125. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_dead_letters.py +0 -0
  126. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_e2e_demo.py +0 -0
  127. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_nack_ack_race.py +0 -0
  128. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_pull.py +0 -0
  129. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_purge.py +0 -0
  130. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_push.py +0 -0
  131. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_release_jobs.py +0 -0
  132. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_rls.py +0 -0
  133. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_schedules.py +0 -0
  134. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_stats.py +0 -0
  135. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_tenancy.py +0 -0
  136. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_tick.py +0 -0
  137. {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_tick_timeouts.py +0 -0
  138. {postkit-0.3.0 → postkit-0.4.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.0
3
+ Version: 0.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "postkit"
3
- version = "0.3.0"
3
+ version = "0.4.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"
@@ -240,6 +240,35 @@ class OutboxErrorCode:
240
240
  BIZ_POSITION_BEYOND_HEAD = "BIZ_POSITION_BEYOND_HEAD"
241
241
 
242
242
 
243
+ class PresenceErrorCode:
244
+ """Error codes for the presence module."""
245
+
246
+ # Validation errors (VAL_)
247
+ VAL_NAMESPACE_NULL = "VAL_NAMESPACE_NULL"
248
+ VAL_NAMESPACE_EMPTY = "VAL_NAMESPACE_EMPTY"
249
+ VAL_NAMESPACE_TOO_LONG = "VAL_NAMESPACE_TOO_LONG"
250
+ VAL_NAMESPACE_INVALID_CHARS = "VAL_NAMESPACE_INVALID_CHARS"
251
+ VAL_NAMESPACE_WHITESPACE = "VAL_NAMESPACE_WHITESPACE"
252
+ VAL_ENTITY_NULL = "VAL_ENTITY_NULL"
253
+ VAL_ENTITY_EMPTY = "VAL_ENTITY_EMPTY"
254
+ VAL_ENTITY_TOO_LONG = "VAL_ENTITY_TOO_LONG"
255
+ VAL_ENTITY_INVALID_CHARS = "VAL_ENTITY_INVALID_CHARS"
256
+ VAL_ENTITY_WHITESPACE = "VAL_ENTITY_WHITESPACE"
257
+ VAL_ENTITIES_NULL = "VAL_ENTITIES_NULL"
258
+ VAL_KIND_NULL = "VAL_KIND_NULL"
259
+ VAL_KIND_EMPTY = "VAL_KIND_EMPTY"
260
+ VAL_KIND_TOO_LONG = "VAL_KIND_TOO_LONG"
261
+ VAL_KIND_FORMAT = "VAL_KIND_FORMAT"
262
+ VAL_TIMEOUT_NOT_POSITIVE = "VAL_TIMEOUT_NOT_POSITIVE"
263
+ VAL_STATUS_INVALID = "VAL_STATUS_INVALID"
264
+ VAL_TRIM_INTERVAL_NOT_POSITIVE = "VAL_TRIM_INTERVAL_NOT_POSITIVE"
265
+ VAL_NOT_POSITIVE = "VAL_NOT_POSITIVE"
266
+
267
+ # Business logic errors
268
+ ENTITY_UNKNOWN = "ENTITY_UNKNOWN"
269
+ HOOK_QUEUE_MISSING = "HOOK_QUEUE_MISSING"
270
+
271
+
243
272
  class QueueErrorCode:
244
273
  """Error codes for the queue module."""
245
274
 
@@ -0,0 +1,15 @@
1
+ """Postkit Presence SDK - heartbeat liveness with transition edge detection."""
2
+
3
+ from postkit.errors import PresenceErrorCode
4
+ from postkit.presence.client import (
5
+ PresenceClient,
6
+ PresenceError,
7
+ PresenceValidationError,
8
+ )
9
+
10
+ __all__ = [
11
+ "PresenceClient",
12
+ "PresenceError",
13
+ "PresenceErrorCode",
14
+ "PresenceValidationError",
15
+ ]
@@ -0,0 +1,274 @@
1
+ """Postkit Presence SDK - heartbeat liveness with transition edge detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import timedelta
7
+ from typing import Any
8
+
9
+ from postkit.base import BaseClient, PostkitError
10
+
11
+
12
+ class PresenceError(PostkitError):
13
+ """Exception for presence operations."""
14
+
15
+
16
+ class PresenceValidationError(PresenceError):
17
+ """Raised when input validation fails."""
18
+
19
+
20
+ class PresenceClient(BaseClient):
21
+ """Client for Postkit presence module.
22
+
23
+ Heartbeat liveness for a fleet of entities. Register once, heartbeat
24
+ periodically, and run sweep() on a timer; the module emits an
25
+ append-only transition stream (alive -> dead -> alive) and can enqueue
26
+ alert jobs atomically with each edge when the queue module is
27
+ installed.
28
+
29
+ Example:
30
+ presence = PresenceClient(cursor, namespace="acme")
31
+
32
+ presence.register("worker-7") # once, idempotent
33
+ presence.heartbeat("worker-7") # every 10-60s
34
+
35
+ # From a cron, anywhere:
36
+ for death in presence.sweep():
37
+ page(death["entity_id"])
38
+ """
39
+
40
+ _schema = "presence"
41
+ _error_class = PresenceError
42
+ _module_sqlstate_map = {
43
+ "22023": PresenceValidationError, # invalid_parameter_value
44
+ "22004": PresenceValidationError, # null_value_not_allowed
45
+ "22001": PresenceValidationError, # string_data_right_truncation
46
+ "22026": PresenceValidationError, # string_data_length_mismatch
47
+ }
48
+
49
+ def _apply_actor_context(self) -> None:
50
+ """Apply actor context via presence.set_actor()."""
51
+ self.cursor.execute(
52
+ """SELECT presence.set_actor(
53
+ p_actor_id := %s,
54
+ p_request_id := %s,
55
+ p_on_behalf_of := %s,
56
+ p_reason := %s
57
+ )""",
58
+ (self._actor_id, self._request_id, self._on_behalf_of, self._reason),
59
+ )
60
+
61
+ def register(
62
+ self,
63
+ entity: str,
64
+ *,
65
+ kind: str | None = None,
66
+ timeout: timedelta | None = None,
67
+ metadata: dict[str, Any] | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Register an entity for liveness tracking, or update its attributes.
70
+
71
+ Idempotent: None arguments preserve what is stored, so deploys can
72
+ re-run register safely. Re-registering is an attribute update, not
73
+ a heartbeat - liveness only changes through heartbeat() and
74
+ sweep(). A new entity starts at 'unknown' and cannot die before its
75
+ first heartbeat.
76
+
77
+ Args:
78
+ entity: Entity id (e.g. 'worker-7', 'sensor:eu:42')
79
+ kind: Entity kind, keys the config row. None means 'default'
80
+ for a new entity and keeps the current kind on re-register
81
+ timeout: Per-entity liveness window replacing the kind's
82
+ dead_after. None keeps the current override
83
+ metadata: Metadata stored on the entity; None keeps the
84
+ existing metadata on re-register (new entities start empty)
85
+
86
+ Returns:
87
+ The entity row dict
88
+ """
89
+ result = self._fetch_one(
90
+ """SELECT * FROM presence.register(
91
+ p_namespace := %s,
92
+ p_entity := %s,
93
+ p_kind := %s,
94
+ p_timeout := %s,
95
+ p_metadata := %s::jsonb
96
+ )""",
97
+ (
98
+ self.namespace,
99
+ entity,
100
+ kind,
101
+ timeout,
102
+ json.dumps(metadata) if metadata is not None else None,
103
+ ),
104
+ write=True,
105
+ )
106
+ if result is None:
107
+ raise PresenceError("presence.register returned no row")
108
+ return result
109
+
110
+ def heartbeat(self, entity: str) -> str:
111
+ """Report an entity alive.
112
+
113
+ A dead or never-seen entity revives here and now - the revival
114
+ transition is emitted by this call, never deferred to a sweep. An
115
+ unregistered entity raises (registration is explicit;
116
+ heartbeat_many reports 'unknown' instead of raising).
117
+
118
+ Args:
119
+ entity: Entity id (must be registered)
120
+
121
+ Returns:
122
+ The resulting status (always 'alive')
123
+ """
124
+ result = self._fetch_val(
125
+ "SELECT presence.heartbeat(%s, %s)",
126
+ (self.namespace, entity),
127
+ write=True,
128
+ )
129
+ if result is None:
130
+ raise PresenceError("presence.heartbeat returned no value")
131
+ return str(result)
132
+
133
+ def heartbeat_many(self, entities: list[str]) -> list[dict[str, Any]]:
134
+ """Report a batch of entities alive in one round trip.
135
+
136
+ Per-entity semantics match heartbeat(), including revivals.
137
+ Unregistered entities come back with status 'unknown' instead of
138
+ raising - one typo must not abort a fleet batch.
139
+
140
+ Args:
141
+ entities: Entity ids
142
+
143
+ Returns:
144
+ One dict per distinct entity: entity_id and its resulting status
145
+ """
146
+ return self._fetch_all(
147
+ "SELECT * FROM presence.heartbeat_many(%s, %s)",
148
+ (self.namespace, entities),
149
+ write=True,
150
+ )
151
+
152
+ def sweep(self, *, limit: int = 1000) -> list[dict[str, Any]]:
153
+ """Mark overdue entities dead and deliver deferred death alerts.
154
+
155
+ Call from a cron or timer; nothing runs on its own, and death is
156
+ detected no faster than the sweep cadence. Deaths emit transitions
157
+ and fire the configured queue hooks in the same transaction.
158
+
159
+ Args:
160
+ limit: Maximum entities to process per call
161
+
162
+ Returns:
163
+ The death transitions emitted by this call
164
+ """
165
+ return self._fetch_all(
166
+ "SELECT * FROM presence.sweep(%s, %s)",
167
+ (self.namespace, limit),
168
+ write=True,
169
+ )
170
+
171
+ def deregister(self, entity: str) -> bool:
172
+ """Remove an entity deliberately, emitting a departed transition.
173
+
174
+ Intentional exit is not death: no death hooks fire and nobody gets
175
+ paged for a planned shutdown. Idempotent - removing an absent
176
+ entity returns False, never raises.
177
+
178
+ Args:
179
+ entity: Entity id
180
+
181
+ Returns:
182
+ True if the entity existed and was removed
183
+ """
184
+ result = self._fetch_val(
185
+ "SELECT presence.deregister(%s, %s)",
186
+ (self.namespace, entity),
187
+ write=True,
188
+ )
189
+ return bool(result)
190
+
191
+ def status(self, entity: str) -> dict[str, Any] | None:
192
+ """Inspect one entity, with the wall-clock truth alongside the cache.
193
+
194
+ The stored status lags until the next sweep; the returned overdue
195
+ flag is true when the entity is nominally alive but already past
196
+ its liveness window.
197
+
198
+ Args:
199
+ entity: Entity id
200
+
201
+ Returns:
202
+ Dict with the liveness fields plus overdue, or None when the
203
+ entity is not registered
204
+ """
205
+ return self._fetch_one(
206
+ "SELECT * FROM presence.status(%s, %s)", (self.namespace, entity)
207
+ )
208
+
209
+ def list_entities(
210
+ self, *, kind: str | None = None, status: str | None = None
211
+ ) -> list[dict[str, Any]]:
212
+ """List entities in the namespace.
213
+
214
+ Args:
215
+ kind: Kind filter (None = all kinds)
216
+ status: Status filter: 'unknown', 'alive', or 'dead'
217
+
218
+ Returns:
219
+ List of entity row dicts
220
+ """
221
+ return self._fetch_all(
222
+ "SELECT * FROM presence.list(%s, %s, %s)",
223
+ (self.namespace, kind, status),
224
+ )
225
+
226
+ def get_transitions(
227
+ self, entity: str | None = None, *, limit: int = 100
228
+ ) -> list[dict[str, Any]]:
229
+ """Read the transition history, newest first.
230
+
231
+ History, not a feed: delivery is the queue hooks and NOTIFY; do
232
+ not poll this by id.
233
+
234
+ Args:
235
+ entity: Entity filter (None = all entities)
236
+ limit: Maximum transitions to return
237
+
238
+ Returns:
239
+ List of transition dicts with actor context
240
+ """
241
+ return self._fetch_all(
242
+ "SELECT * FROM presence.get_transitions(%s, %s, %s)",
243
+ (self.namespace, entity, limit),
244
+ )
245
+
246
+ def get_stats(self) -> dict[str, Any]:
247
+ """Get namespace-wide presence statistics.
248
+
249
+ Returns:
250
+ Dict with total_entities, alive, dead, unknown, overdue, and
251
+ total_transitions counts
252
+ """
253
+ result = self._fetch_one(
254
+ "SELECT * FROM presence.get_stats(%s)", (self.namespace,)
255
+ )
256
+ return result or {}
257
+
258
+ def trim(
259
+ self, older_than: timedelta, *, limit: int = 10000
260
+ ) -> list[dict[str, Any]]:
261
+ """Delete old transitions. Retention has no default; pass it explicitly.
262
+
263
+ Args:
264
+ older_than: Delete transitions older than this (required, positive)
265
+ limit: Maximum transitions to delete per call
266
+
267
+ Returns:
268
+ One dict per namespace touched, with the deleted count
269
+ """
270
+ return self._fetch_all(
271
+ "SELECT * FROM presence.trim(%s, %s, %s)",
272
+ (older_than, self.namespace, limit),
273
+ write=True,
274
+ )
@@ -0,0 +1,189 @@
1
+ """presence + queue composition: transition hooks enqueue jobs atomically.
2
+
3
+ These tests need both schemas installed, so they live in the integration
4
+ suite (the module suites run in parallel workers and must not depend on
5
+ each other's schemas being present or absent).
6
+ """
7
+
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ import psycopg
12
+ import pytest
13
+ from tests.conftest import DATABASE_URL
14
+ from tests.helpers import make_namespace
15
+ from tests.presence.helpers import PresenceTestHelpers
16
+ from tests.presence.helpers import cleanup_namespace as _cleanup_presence
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def hooks_connection():
21
+ """Module-scoped connection with presence AND queue installed."""
22
+ conn = psycopg.connect(DATABASE_URL, autocommit=True)
23
+ conn.execute("DROP SCHEMA IF EXISTS presence CASCADE")
24
+ conn.execute("DROP SCHEMA IF EXISTS queue CASCADE")
25
+
26
+ dist_dir = Path(__file__).parent.parent.parent.parent / "dist"
27
+ for schema in ["presence", "queue"]:
28
+ sql_file = dist_dir / f"{schema}.sql"
29
+ if not sql_file.exists():
30
+ pytest.fail(f"dist/{schema}.sql not found. Run 'make build' first.")
31
+ conn.execute(sql_file.read_text())
32
+
33
+ yield conn
34
+
35
+ conn.execute("DROP SCHEMA IF EXISTS presence CASCADE")
36
+ conn.execute("DROP SCHEMA IF EXISTS queue CASCADE")
37
+ conn.close()
38
+
39
+
40
+ @pytest.fixture
41
+ def helpers(hooks_connection, request):
42
+ namespace = make_namespace(request)
43
+ cursor = hooks_connection.cursor()
44
+ h = PresenceTestHelpers(cursor, namespace)
45
+ cursor.execute("SELECT queue.set_tenant(%s)", (namespace,))
46
+
47
+ yield h
48
+
49
+ _cleanup_presence(cursor, namespace)
50
+ for table in ("jobs", "dead_letters", "schedules", "config"):
51
+ cursor.execute(f"DELETE FROM queue.{table} WHERE namespace = %s", (namespace,))
52
+ cursor.close()
53
+
54
+
55
+ def register_and_kill_setup(h, entity="w1", **config):
56
+ """Register a live entity with a death hook configured, then backdate it."""
57
+ h.set_config(on_death_queue="alerts", **config)
58
+ h.cursor.execute("SELECT presence.register(%s, %s)", (h.namespace, entity))
59
+ h.cursor.execute("SELECT presence.heartbeat(%s, %s)", (h.namespace, entity))
60
+ h.set_last_seen(entity, "-10 minutes")
61
+
62
+
63
+ def alert_jobs(h) -> list[dict]:
64
+ h.cursor.execute(
65
+ "SELECT payload FROM queue.jobs "
66
+ "WHERE namespace = %s AND queue = 'alerts' ORDER BY id",
67
+ (h.namespace,),
68
+ )
69
+ return [r[0] for r in h.cursor.fetchall()]
70
+
71
+
72
+ class TestDeathHook:
73
+ def test_death_pushes_exactly_one_job(self, helpers):
74
+ register_and_kill_setup(helpers)
75
+ helpers.cursor.execute(
76
+ "SELECT entity_id FROM presence.sweep(%s)", (helpers.namespace,)
77
+ )
78
+ assert [r[0] for r in helpers.cursor.fetchall()] == ["w1"]
79
+
80
+ jobs = alert_jobs(helpers)
81
+ assert len(jobs) == 1
82
+ assert jobs[0]["entity_id"] == "w1"
83
+ assert jobs[0]["to"] == "dead"
84
+ assert jobs[0]["silent_for"] is not None
85
+
86
+ def test_death_and_job_commit_together(self, hooks_connection, helpers):
87
+ """The atomicity claim: roll back the sweeping transaction and
88
+ neither the death nor the alert job exists."""
89
+ register_and_kill_setup(helpers)
90
+
91
+ info = hooks_connection.info
92
+ conn = psycopg.connect(
93
+ host=info.host,
94
+ port=info.port,
95
+ dbname=info.dbname,
96
+ user=info.user,
97
+ password=info.password,
98
+ )
99
+ cur = conn.cursor()
100
+ cur.execute("SELECT entity_id FROM presence.sweep(%s)", (helpers.namespace,))
101
+ assert [r[0] for r in cur.fetchall()] == ["w1"]
102
+ conn.rollback()
103
+ conn.close()
104
+
105
+ assert alert_jobs(helpers) == []
106
+ assert helpers.get_entity_row("w1")["status"] == "alive"
107
+ assert helpers.get_transitions("w1")[-1]["to_status"] == "alive"
108
+
109
+ def test_revival_hook_fires_on_revival(self, helpers):
110
+ helpers.set_config(on_revival_queue="alerts")
111
+ helpers.cursor.execute(
112
+ "SELECT presence.register(%s, 'w1')", (helpers.namespace,)
113
+ )
114
+ helpers.cursor.execute(
115
+ "SELECT presence.heartbeat(%s, 'w1')", (helpers.namespace,)
116
+ )
117
+
118
+ jobs = alert_jobs(helpers)
119
+ assert len(jobs) == 1
120
+ assert jobs[0]["to"] == "alive"
121
+ assert jobs[0]["from"] == "unknown"
122
+
123
+
124
+ class TestDeferredTerminalAlert:
125
+ def test_flap_into_suppression_then_real_death_alerts_once(self, helpers):
126
+ """The P5 catch-up: a death suppressed by flap damping is delivered
127
+ by a later sweep once the window expires - exactly one job, no new
128
+ transitions row, flag cleared, payload carrying the REAL death
129
+ time."""
130
+ register_and_kill_setup(helpers, flap_threshold=1)
131
+ # first contact was edge 1; this death is edge 2 > threshold 1
132
+ helpers.cursor.execute(
133
+ "SELECT entity_id FROM presence.sweep(%s)", (helpers.namespace,)
134
+ )
135
+ assert [r[0] for r in helpers.cursor.fetchall()] == ["w1"]
136
+ assert alert_jobs(helpers) == [] # suppressed
137
+ row = helpers.get_entity_row("w1")
138
+ assert row["hook_suppressed"] is True
139
+ transitions_before = len(helpers.get_transitions("w1"))
140
+
141
+ # The flap window expires while the entity is still dead
142
+ helpers.set_flap_window_started("w1", "-1 hour")
143
+ helpers.cursor.execute(
144
+ "SELECT entity_id FROM presence.sweep(%s)", (helpers.namespace,)
145
+ )
146
+ assert helpers.cursor.fetchall() == [] # no new transition returned
147
+
148
+ jobs = alert_jobs(helpers)
149
+ assert len(jobs) == 1
150
+ assert jobs[0]["to"] == "dead"
151
+ row = helpers.get_entity_row("w1")
152
+ assert row["hook_suppressed"] is False
153
+ assert len(helpers.get_transitions("w1")) == transitions_before
154
+ # at carries the real death time, not the catch-up sweep's time
155
+ assert datetime.fromisoformat(jobs[0]["at"]) == row["dead_since"]
156
+
157
+ def test_catch_up_fires_only_once(self, helpers):
158
+ register_and_kill_setup(helpers, flap_threshold=1)
159
+ helpers.cursor.execute("SELECT * FROM presence.sweep(%s)", (helpers.namespace,))
160
+ helpers.cursor.fetchall()
161
+ helpers.set_flap_window_started("w1", "-1 hour")
162
+
163
+ for _ in range(3):
164
+ helpers.cursor.execute(
165
+ "SELECT * FROM presence.sweep(%s)", (helpers.namespace,)
166
+ )
167
+ helpers.cursor.fetchall()
168
+
169
+ assert len(alert_jobs(helpers)) == 1
170
+
171
+
172
+ class TestAllNamespacesSweep:
173
+ def test_bypassrls_sweep_pushes_into_tenant_queue(self, hooks_connection, helpers):
174
+ """The deployment mode: one cron, all namespaces, as a role that
175
+ bypasses RLS for presence must still land the job in the tenant's
176
+ queue rows correctly."""
177
+ register_and_kill_setup(helpers)
178
+
179
+ # The superuser session with NULL namespace is the all-namespaces
180
+ # deployment shape (the test connection is a superuser)
181
+ cur = hooks_connection.cursor()
182
+ cur.execute("SELECT namespace, entity_id FROM presence.sweep(NULL)")
183
+ swept = cur.fetchall()
184
+ assert (helpers.namespace, "w1") in swept
185
+
186
+ jobs = alert_jobs(helpers)
187
+ assert len(jobs) == 1
188
+ assert jobs[0]["namespace"] == helpers.namespace
189
+ cur.close()
File without changes
@@ -0,0 +1,81 @@
1
+ """Pytest fixtures for postkit.presence tests."""
2
+
3
+ import psycopg
4
+ import pytest
5
+ from postkit.presence import PresenceClient
6
+
7
+ from tests.helpers import db_connection_for, make_namespace
8
+ from tests.presence.helpers import PresenceTestHelpers, cleanup_namespace
9
+
10
+
11
+ @pytest.fixture(scope="session")
12
+ def db_connection():
13
+ """Session-scoped database connection with the presence schema installed."""
14
+ yield from db_connection_for("presence")
15
+
16
+
17
+ @pytest.fixture
18
+ def presence(db_connection, request):
19
+ """SDK-style PresenceClient for tests.
20
+
21
+ Each test gets its own namespace for isolation.
22
+ Cleanup is automatic after each test.
23
+ """
24
+ namespace = make_namespace(request)
25
+ cursor = db_connection.cursor()
26
+ client = PresenceClient(cursor, namespace)
27
+
28
+ yield client
29
+
30
+ cleanup_namespace(cursor, namespace)
31
+ cursor.close()
32
+
33
+
34
+ @pytest.fixture
35
+ def test_helpers(db_connection, request):
36
+ """Test helper utilities for direct table access.
37
+
38
+ Each test gets its own namespace for isolation.
39
+ Cleanup is automatic after each test.
40
+ """
41
+ namespace = make_namespace(request)
42
+ cursor = db_connection.cursor()
43
+ helpers = PresenceTestHelpers(cursor, namespace)
44
+
45
+ yield helpers
46
+
47
+ cleanup_namespace(cursor, namespace)
48
+ cursor.close()
49
+
50
+
51
+ @pytest.fixture
52
+ def connect(db_connection):
53
+ """Factory for extra non-autocommit connections (two-connection races).
54
+
55
+ Every connection gets a statement_timeout so a locking regression fails
56
+ the suite loudly instead of hanging CI.
57
+ """
58
+ conns: list[psycopg.Connection] = []
59
+ info = db_connection.info
60
+
61
+ def _connect(statement_timeout_ms: int = 10000) -> psycopg.Connection:
62
+ conn = psycopg.connect(
63
+ host=info.host,
64
+ port=info.port,
65
+ dbname=info.dbname,
66
+ user=info.user,
67
+ password=info.password,
68
+ )
69
+ conn.execute(f"SET statement_timeout = {statement_timeout_ms}")
70
+ conn.commit()
71
+ conns.append(conn)
72
+ return conn
73
+
74
+ yield _connect
75
+
76
+ for conn in conns:
77
+ try:
78
+ conn.rollback()
79
+ conn.close()
80
+ except Exception:
81
+ pass