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.
- {postkit-0.3.0 → postkit-0.4.0}/PKG-INFO +1 -1
- {postkit-0.3.0 → postkit-0.4.0}/pyproject.toml +1 -1
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/errors.py +29 -0
- postkit-0.4.0/src/postkit/presence/__init__.py +15 -0
- postkit-0.4.0/src/postkit/presence/client.py +274 -0
- postkit-0.4.0/tests/integration/test_presence_hooks.py +189 -0
- postkit-0.4.0/tests/presence/__init__.py +0 -0
- postkit-0.4.0/tests/presence/conftest.py +81 -0
- postkit-0.4.0/tests/presence/helpers.py +88 -0
- postkit-0.4.0/tests/presence/test_flap.py +146 -0
- postkit-0.4.0/tests/presence/test_heartbeat.py +102 -0
- postkit-0.4.0/tests/presence/test_invariants.py +189 -0
- postkit-0.4.0/tests/presence/test_maintenance.py +98 -0
- postkit-0.4.0/tests/presence/test_state_machine.py +177 -0
- postkit-0.4.0/tests/presence/test_tenancy.py +108 -0
- postkit-0.4.0/tests/presence/test_validation.py +100 -0
- postkit-0.4.0/tests/presence/test_wall_clock.py +56 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/test_error_codes.py +2 -0
- {postkit-0.3.0 → postkit-0.4.0}/uv.lock +1 -1
- {postkit-0.3.0 → postkit-0.4.0}/.gitignore +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/README.md +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authn/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authn/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authz/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/authz/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/base.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/config/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/config/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/lease/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/lease/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/meter/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/meter/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/outbox/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/outbox/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/py.typed +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/queue/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/src/postkit/queue/client.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_audit.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_base.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_credentials.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_disabled_user.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_e2e_demo.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_impersonation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_lockout.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_operator_impersonation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_refresh_tokens.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_sessions.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_tokens.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_users.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authn/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_access_patterns.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_audit.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_concurrency.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_consistency.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_e2e_demo.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_expiration.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_groups.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_hierarchy.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_listing.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_namespaces.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_nested_teams.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_resource_hierarchy.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_sdk.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_stress.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_transactions.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/authz/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_audit.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_entries.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_schemas.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/config/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/integration/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/integration/test_api_keys.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_acquire.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_events.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_invariants.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_renew_release.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/lease/test_verify_fencing.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_periods.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_reservation_expiry_race.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_sdk.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/meter/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_emit.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_horizon.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_lost_cursor.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_maintenance.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_subscribe_poll_ack.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/outbox/test_validation.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/__init__.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/conftest.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/helpers.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_ack.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_cron.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_dead_letters.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_e2e_demo.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_nack_ack_race.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_pull.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_purge.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_push.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_release_jobs.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_rls.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_schedules.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_stats.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_tenancy.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_tick.py +0 -0
- {postkit-0.3.0 → postkit-0.4.0}/tests/queue/test_tick_timeouts.py +0 -0
- {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
|
+
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
|
|
@@ -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
|