patchr 0.1.0__py3-none-any.whl
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.
- apps/__init__.py +2 -0
- apps/api/__init__.py +2 -0
- apps/api/main.py +652 -0
- apps/benchmarks/__init__.py +1 -0
- apps/benchmarks/main.py +20 -0
- apps/sandbox/__init__.py +1 -0
- apps/sandbox/main.py +20 -0
- apps/worker/__init__.py +2 -0
- apps/worker/main.py +15 -0
- apps/worker/verify.py +14 -0
- patchr/__init__.py +12 -0
- patchr/sdk/__init__.py +20 -0
- patchr/sdk/client.py +12 -0
- patchr-0.1.0.dist-info/METADATA +137 -0
- patchr-0.1.0.dist-info/RECORD +116 -0
- patchr-0.1.0.dist-info/WHEEL +5 -0
- patchr-0.1.0.dist-info/entry_points.txt +5 -0
- patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
- patchr-0.1.0.dist-info/top_level.txt +3 -0
- picux/__init__.py +6 -0
- picux/agents/__init__.py +5 -0
- picux/agents/registry.py +204 -0
- picux/api/__init__.py +5 -0
- picux/api/service.py +5075 -0
- picux/audit/__init__.py +31 -0
- picux/audit/activity.py +97 -0
- picux/audit/observability.py +55 -0
- picux/audit/verification/__init__.py +21 -0
- picux/audit/verification/ledger.py +633 -0
- picux/benchmarks/__init__.py +5 -0
- picux/benchmarks/local.py +286 -0
- picux/config.py +140 -0
- picux/contracts/__init__.py +22 -0
- picux/contracts/handshake.py +122 -0
- picux/contracts/integration.py +385 -0
- picux/contracts/openapi.py +187 -0
- picux/contracts/protocol_map.py +152 -0
- picux/contracts/routes.py +980 -0
- picux/contracts/schema_catalog.py +125 -0
- picux/core/__init__.py +17 -0
- picux/core/models.py +148 -0
- picux/core/router.py +131 -0
- picux/core/runtime.py +42 -0
- picux/core/state_machine.py +38 -0
- picux/domains/__init__.py +2 -0
- picux/domains/bridge/HostRun.py +1104 -0
- picux/domains/bridge/__init__.py +6 -0
- picux/domains/bridge/engine.py +345 -0
- picux/domains/hunt/__init__.py +6 -0
- picux/domains/hunt/engine.py +307 -0
- picux/domains/hunt/models.py +88 -0
- picux/domains/pay/__init__.py +16 -0
- picux/domains/pay/adapters.py +607 -0
- picux/domains/pay/engine.py +950 -0
- picux/domains/pay/models.py +95 -0
- picux/domains/proxy/__init__.py +5 -0
- picux/domains/proxy/engine.py +466 -0
- picux/domains/resolve/__init__.py +5 -0
- picux/domains/resolve/engine.py +546 -0
- picux/orchestrator/__init__.py +3 -0
- picux/orchestrator/engine.py +2840 -0
- picux/portals/__init__.py +17 -0
- picux/portals/templates.py +272 -0
- picux/protocols/__init__.py +1 -0
- picux/protocols/a2a/__init__.py +6 -0
- picux/protocols/a2a/client.py +51 -0
- picux/protocols/a2a/envelope.py +132 -0
- picux/protocols/mcp/__init__.py +7 -0
- picux/protocols/mcp/client.py +69 -0
- picux/protocols/mcp/contract.py +67 -0
- picux/protocols/mcp/server.py +76 -0
- picux/sandbox/__init__.py +6 -0
- picux/sandbox/midnight_arbitrage.py +215 -0
- picux/sandbox/models.py +90 -0
- picux/sdk/__init__.py +13 -0
- picux/sdk/client.py +768 -0
- picux/sdk/external.py +245 -0
- picux/security/__init__.py +18 -0
- picux/security/auth.py +86 -0
- picux/security/config_validator.py +58 -0
- picux/security/policy.py +158 -0
- picux/security/secrets.py +144 -0
- picux/signals/__init__.py +1 -0
- picux/signals/community/__init__.py +24 -0
- picux/signals/community/adapters/__init__.py +7 -0
- picux/signals/community/adapters/reddit.py +37 -0
- picux/signals/community/adapters/shopify.py +23 -0
- picux/signals/community/adapters/web.py +23 -0
- picux/signals/community/disambiguation.py +51 -0
- picux/signals/community/intake.py +227 -0
- picux/signals/community/models.py +102 -0
- picux/signals/community/rules.py +91 -0
- picux/signals/community/scoring.py +64 -0
- picux/storage/__init__.py +41 -0
- picux/storage/agents.py +50 -0
- picux/storage/cases.py +440 -0
- picux/storage/channels.py +476 -0
- picux/storage/connectors.py +411 -0
- picux/storage/envelopes.py +137 -0
- picux/storage/escrows.py +168 -0
- picux/storage/events.py +989 -0
- picux/storage/keyspace.py +60 -0
- picux/storage/mandates.py +107 -0
- picux/storage/portals.py +222 -0
- picux/storage/postgres.py +2049 -0
- picux/storage/providers.py +148 -0
- picux/storage/proxy.py +231 -0
- picux/storage/receipts.py +131 -0
- picux/storage/signals.py +147 -0
- picux/storage/tasks.py +179 -0
- picux/tools/__init__.py +11 -0
- picux/tools/shared.py +2048 -0
- picux/verification/__init__.py +5 -0
- picux/verification/rollout.py +183 -0
- picux/workflows/__init__.py +5 -0
- picux/workflows/templates.py +74 -0
picux/storage/events.py
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
EVENT_STATUSES = {"queued", "acked", "failed", "canceled"}
|
|
11
|
+
SUBSCRIPTION_STATUSES = {"active", "paused", "disabled"}
|
|
12
|
+
SUBSCRIPTION_TRANSPORTS = {"poll", "webhook", "a2a", "mcp"}
|
|
13
|
+
DELIVERY_STATUSES = {"queued", "claimed", "delivered", "failed", "canceled"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventBook:
|
|
17
|
+
"""External integration event outbox with optional durable backing."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
backing: Any | None = None,
|
|
23
|
+
subscriptionBook: "EventSubscriptionBook | None" = None,
|
|
24
|
+
deliveryBook: "EventDeliveryBook | None" = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.backing = backing
|
|
27
|
+
self.subscriptionBook = subscriptionBook
|
|
28
|
+
self.deliveryBook = deliveryBook
|
|
29
|
+
self._records: dict[str, dict[str, Any]] = {}
|
|
30
|
+
|
|
31
|
+
def recordEvent(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
eventType = str(payload.get("type", payload.get("event", "")) or "").strip()
|
|
33
|
+
if not eventType:
|
|
34
|
+
return {"ok": False, "error": "missing:type"}
|
|
35
|
+
now = int(payload.get("createdAt", 0) or time.time())
|
|
36
|
+
eventId = str(payload.get("eventId", "") or _stableId(eventType, payload, now))
|
|
37
|
+
current = self.getEvent(eventId)
|
|
38
|
+
createdAt = current.get("event", {}).get("createdAt", now) if current.get("ok") else now
|
|
39
|
+
event = {
|
|
40
|
+
"type": eventType,
|
|
41
|
+
"subject": str(payload.get("subject", "") or ""),
|
|
42
|
+
"source": str(payload.get("source", "picux") or "picux"),
|
|
43
|
+
"target": str(payload.get("target", "") or ""),
|
|
44
|
+
"payload": payload.get("payload", {}) if isinstance(payload.get("payload"), dict) else {},
|
|
45
|
+
}
|
|
46
|
+
subscriptions = self._matchedSubscriptionRecords(event)
|
|
47
|
+
record = {
|
|
48
|
+
"eventId": eventId,
|
|
49
|
+
"type": eventType,
|
|
50
|
+
"subject": event["subject"],
|
|
51
|
+
"source": event["source"],
|
|
52
|
+
"target": event["target"],
|
|
53
|
+
"status": _status(payload.get("status", "queued")),
|
|
54
|
+
"payload": copy.deepcopy(payload.get("payload", {}) if isinstance(payload.get("payload"), dict) else {}),
|
|
55
|
+
"subscriptions": [str(record.get("subId", "") or "") for record in subscriptions if record.get("subId")],
|
|
56
|
+
"ack": copy.deepcopy(payload.get("ack", {}) if isinstance(payload.get("ack"), dict) else {}),
|
|
57
|
+
"createdAt": int(createdAt or now),
|
|
58
|
+
"updatedAt": int(payload.get("updatedAt", now) or now),
|
|
59
|
+
"ackAt": int(payload.get("ackAt", 0) or 0),
|
|
60
|
+
}
|
|
61
|
+
self._saveRecord(record)
|
|
62
|
+
deliveries = self._recordDeliveries(record, subscriptions)
|
|
63
|
+
result = {"ok": True, "event": copy.deepcopy(record)}
|
|
64
|
+
if deliveries:
|
|
65
|
+
result["deliveries"] = deliveries
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
def getEvent(self, eventId: str) -> dict[str, Any]:
|
|
69
|
+
eventId = str(eventId or "")
|
|
70
|
+
record = self._records.get(eventId)
|
|
71
|
+
if record is None and self._backingEnabled() and hasattr(self.backing, "fetchIntegrationEvent"):
|
|
72
|
+
record = self.backing.fetchIntegrationEvent(eventId)
|
|
73
|
+
if record:
|
|
74
|
+
self._records[eventId] = copy.deepcopy(record)
|
|
75
|
+
if not record:
|
|
76
|
+
return {"ok": False, "error": "eventNotFound", "eventId": eventId}
|
|
77
|
+
return {"ok": True, "event": copy.deepcopy(record)}
|
|
78
|
+
|
|
79
|
+
def listEvents(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
80
|
+
self._loadBacking()
|
|
81
|
+
filters = filters or {}
|
|
82
|
+
eventType = str(filters.get("type", filters.get("event", "")) or "")
|
|
83
|
+
source = str(filters.get("source", "") or "")
|
|
84
|
+
target = str(filters.get("target", "") or "")
|
|
85
|
+
subject = str(filters.get("subject", "") or "")
|
|
86
|
+
status = str(filters.get("status", "") or "")
|
|
87
|
+
records = list(self._records.values())
|
|
88
|
+
if eventType:
|
|
89
|
+
records = [record for record in records if record.get("type") == eventType]
|
|
90
|
+
if source:
|
|
91
|
+
records = [record for record in records if record.get("source") == source]
|
|
92
|
+
if target:
|
|
93
|
+
records = [record for record in records if record.get("target") == target]
|
|
94
|
+
if subject:
|
|
95
|
+
records = [record for record in records if record.get("subject") == subject]
|
|
96
|
+
if status:
|
|
97
|
+
records = [record for record in records if record.get("status") == status]
|
|
98
|
+
records.sort(key=lambda item: (int(item.get("createdAt", 0) or 0), str(item.get("eventId", ""))), reverse=True)
|
|
99
|
+
limit = _limit(filters.get("limit", 100))
|
|
100
|
+
return {
|
|
101
|
+
"ok": True,
|
|
102
|
+
"events": [copy.deepcopy(record) for record in records[:limit]],
|
|
103
|
+
"count": min(len(records), limit),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def ackEvent(self, eventId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
107
|
+
current = self.getEvent(eventId)
|
|
108
|
+
if not current.get("ok"):
|
|
109
|
+
return current
|
|
110
|
+
now = int(time.time())
|
|
111
|
+
payload = payload or {}
|
|
112
|
+
record = current["event"]
|
|
113
|
+
ack = payload.get("ack", payload) if isinstance(payload.get("ack", payload), dict) else {}
|
|
114
|
+
merged = {
|
|
115
|
+
**record,
|
|
116
|
+
"status": "acked",
|
|
117
|
+
"ack": copy.deepcopy(ack),
|
|
118
|
+
"ackAt": int(payload.get("ackAt", now) or now),
|
|
119
|
+
"updatedAt": int(payload.get("updatedAt", now) or now),
|
|
120
|
+
}
|
|
121
|
+
self._saveRecord(merged)
|
|
122
|
+
return {"ok": True, "event": copy.deepcopy(merged)}
|
|
123
|
+
|
|
124
|
+
def _saveRecord(self, record: dict[str, Any]) -> None:
|
|
125
|
+
eventId = str(record.get("eventId", "") or "")
|
|
126
|
+
if not eventId:
|
|
127
|
+
return
|
|
128
|
+
self._records[eventId] = copy.deepcopy(record)
|
|
129
|
+
if self._backingEnabled() and hasattr(self.backing, "upsertIntegrationEvent"):
|
|
130
|
+
self.backing.upsertIntegrationEvent(record)
|
|
131
|
+
|
|
132
|
+
def _loadBacking(self) -> None:
|
|
133
|
+
if self.backing is None or not self._backingEnabled() or not hasattr(self.backing, "listIntegrationEvents"):
|
|
134
|
+
return
|
|
135
|
+
for record in self.backing.listIntegrationEvents():
|
|
136
|
+
eventId = str(record.get("eventId", "") or "")
|
|
137
|
+
if eventId:
|
|
138
|
+
self._records[eventId] = copy.deepcopy(record)
|
|
139
|
+
|
|
140
|
+
def _backingEnabled(self) -> bool:
|
|
141
|
+
if self.backing is None:
|
|
142
|
+
return False
|
|
143
|
+
return bool(getattr(self.backing, "enabled", True))
|
|
144
|
+
|
|
145
|
+
def _matchedSubscriptionRecords(self, event: dict[str, Any]) -> list[dict[str, Any]]:
|
|
146
|
+
if self.subscriptionBook is None:
|
|
147
|
+
return []
|
|
148
|
+
result = self.subscriptionBook.matchEvent(event)
|
|
149
|
+
if not result.get("ok"):
|
|
150
|
+
return []
|
|
151
|
+
return [
|
|
152
|
+
copy.deepcopy(record)
|
|
153
|
+
for record in result.get("subscriptions", [])
|
|
154
|
+
if isinstance(record, dict) and record.get("subId")
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
def _recordDeliveries(self, event: dict[str, Any], subscriptions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
158
|
+
if self.deliveryBook is None:
|
|
159
|
+
return []
|
|
160
|
+
deliveries: list[dict[str, Any]] = []
|
|
161
|
+
for subscription in subscriptions:
|
|
162
|
+
result = self.deliveryBook.createForEvent(event, subscription)
|
|
163
|
+
if result.get("ok") and isinstance(result.get("delivery"), dict):
|
|
164
|
+
deliveries.append(copy.deepcopy(result["delivery"]))
|
|
165
|
+
return deliveries
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class EventSubscriptionBook:
|
|
169
|
+
"""Event interest registry for external apps, services, and agents."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, *, backing: Any | None = None) -> None:
|
|
172
|
+
self.backing = backing
|
|
173
|
+
self._records: dict[str, dict[str, Any]] = {}
|
|
174
|
+
|
|
175
|
+
def createSubscription(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
clientId = str(payload.get("clientId", payload.get("target", "")) or "").strip()
|
|
177
|
+
eventTypes = _cleanList(payload.get("eventTypes", payload.get("types", [])))
|
|
178
|
+
errors = _subscriptionErrors(clientId=clientId, eventTypes=eventTypes, payload=payload)
|
|
179
|
+
subId = str(payload.get("subId", "") or _subscriptionId(clientId, eventTypes, payload))
|
|
180
|
+
record = self._record(payload, subId=subId, clientId=clientId, eventTypes=eventTypes)
|
|
181
|
+
if errors:
|
|
182
|
+
return {"ok": False, "errors": errors, "subscription": record}
|
|
183
|
+
self._saveRecord(record)
|
|
184
|
+
return {"ok": True, "subscription": copy.deepcopy(record)}
|
|
185
|
+
|
|
186
|
+
def getSubscription(self, subId: str) -> dict[str, Any]:
|
|
187
|
+
subId = str(subId or "")
|
|
188
|
+
record = self._records.get(subId)
|
|
189
|
+
if record is None and self._backingEnabled() and hasattr(self.backing, "fetchEventSubscription"):
|
|
190
|
+
record = self.backing.fetchEventSubscription(subId)
|
|
191
|
+
if record:
|
|
192
|
+
self._records[subId] = copy.deepcopy(record)
|
|
193
|
+
if not record:
|
|
194
|
+
return {"ok": False, "error": "subscriptionNotFound", "subId": subId}
|
|
195
|
+
return {"ok": True, "subscription": copy.deepcopy(record)}
|
|
196
|
+
|
|
197
|
+
def listSubscriptions(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
198
|
+
self._loadBacking()
|
|
199
|
+
filters = filters or {}
|
|
200
|
+
clientId = str(filters.get("clientId", "") or "")
|
|
201
|
+
eventType = str(filters.get("eventType", filters.get("type", "")) or "")
|
|
202
|
+
status = str(filters.get("status", "") or "")
|
|
203
|
+
transport = str(filters.get("transport", "") or "")
|
|
204
|
+
records = list(self._records.values())
|
|
205
|
+
if clientId:
|
|
206
|
+
records = [record for record in records if record.get("clientId") == clientId]
|
|
207
|
+
if eventType:
|
|
208
|
+
records = [record for record in records if eventType in record.get("eventTypes", []) or "*" in record.get("eventTypes", [])]
|
|
209
|
+
if status:
|
|
210
|
+
records = [record for record in records if record.get("status") == status]
|
|
211
|
+
if transport:
|
|
212
|
+
records = [record for record in records if record.get("transport") == transport]
|
|
213
|
+
records.sort(key=lambda item: (int(item.get("createdAt", 0) or 0), str(item.get("subId", ""))), reverse=True)
|
|
214
|
+
limit = _limit(filters.get("limit", 100))
|
|
215
|
+
return {
|
|
216
|
+
"ok": True,
|
|
217
|
+
"subscriptions": [copy.deepcopy(record) for record in records[:limit]],
|
|
218
|
+
"count": min(len(records), limit),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def updateSubscription(self, subId: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
current = self.getSubscription(subId)
|
|
223
|
+
if not current.get("ok"):
|
|
224
|
+
return current
|
|
225
|
+
record = current["subscription"]
|
|
226
|
+
eventTypes = _cleanList(updates.get("eventTypes", updates.get("types", record.get("eventTypes", []))))
|
|
227
|
+
merged = self._record(
|
|
228
|
+
{**record, **updates},
|
|
229
|
+
subId=subId,
|
|
230
|
+
clientId=str(updates.get("clientId", record.get("clientId", "")) or ""),
|
|
231
|
+
eventTypes=eventTypes,
|
|
232
|
+
createdAt=int(record.get("createdAt", 0) or 0),
|
|
233
|
+
)
|
|
234
|
+
errors = _subscriptionErrors(clientId=merged["clientId"], eventTypes=eventTypes, payload=merged)
|
|
235
|
+
if errors:
|
|
236
|
+
return {"ok": False, "errors": errors, "subscription": merged}
|
|
237
|
+
self._saveRecord(merged)
|
|
238
|
+
return {"ok": True, "subscription": copy.deepcopy(merged)}
|
|
239
|
+
|
|
240
|
+
def matchEvent(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
241
|
+
self._loadBacking()
|
|
242
|
+
eventType = str(event.get("type", event.get("event", "")) or "")
|
|
243
|
+
target = str(event.get("target", "") or "")
|
|
244
|
+
source = str(event.get("source", "") or "")
|
|
245
|
+
subject = str(event.get("subject", "") or "")
|
|
246
|
+
matches: list[dict[str, Any]] = []
|
|
247
|
+
for record in self._records.values():
|
|
248
|
+
filters = record.get("filters", {}) if isinstance(record.get("filters"), dict) else {}
|
|
249
|
+
if record.get("status") != "active":
|
|
250
|
+
continue
|
|
251
|
+
if eventType not in record.get("eventTypes", []) and "*" not in record.get("eventTypes", []):
|
|
252
|
+
continue
|
|
253
|
+
if record.get("target") and record.get("target") != target:
|
|
254
|
+
continue
|
|
255
|
+
if filters.get("source") and filters.get("source") != source:
|
|
256
|
+
continue
|
|
257
|
+
if filters.get("subject") and filters.get("subject") != subject:
|
|
258
|
+
continue
|
|
259
|
+
matches.append(copy.deepcopy(record))
|
|
260
|
+
matches.sort(key=lambda item: str(item.get("subId", "")))
|
|
261
|
+
return {"ok": True, "subscriptions": matches, "count": len(matches)}
|
|
262
|
+
|
|
263
|
+
def _record(
|
|
264
|
+
self,
|
|
265
|
+
payload: dict[str, Any],
|
|
266
|
+
*,
|
|
267
|
+
subId: str,
|
|
268
|
+
clientId: str,
|
|
269
|
+
eventTypes: list[str],
|
|
270
|
+
createdAt: int | None = None,
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
now = int(time.time())
|
|
273
|
+
return {
|
|
274
|
+
"subId": subId,
|
|
275
|
+
"clientId": clientId,
|
|
276
|
+
"eventTypes": eventTypes,
|
|
277
|
+
"target": str(payload.get("target", clientId) or ""),
|
|
278
|
+
"transport": _transport(payload.get("transport", "poll")),
|
|
279
|
+
"endpoint": str(payload.get("endpoint", "") or ""),
|
|
280
|
+
"status": _subStatus(payload.get("status", "active")),
|
|
281
|
+
"filters": copy.deepcopy(payload.get("filters", {}) if isinstance(payload.get("filters"), dict) else {}),
|
|
282
|
+
"meta": copy.deepcopy(payload.get("meta", {}) if isinstance(payload.get("meta"), dict) else {}),
|
|
283
|
+
"createdAt": int(createdAt or payload.get("createdAt", now) or now),
|
|
284
|
+
"updatedAt": int(payload.get("updatedAt", now) or now),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def _saveRecord(self, record: dict[str, Any]) -> None:
|
|
288
|
+
subId = str(record.get("subId", "") or "")
|
|
289
|
+
if not subId:
|
|
290
|
+
return
|
|
291
|
+
self._records[subId] = copy.deepcopy(record)
|
|
292
|
+
if self._backingEnabled() and hasattr(self.backing, "upsertEventSubscription"):
|
|
293
|
+
self.backing.upsertEventSubscription(record)
|
|
294
|
+
|
|
295
|
+
def _loadBacking(self) -> None:
|
|
296
|
+
if self.backing is None or not self._backingEnabled() or not hasattr(self.backing, "listEventSubscriptions"):
|
|
297
|
+
return
|
|
298
|
+
for record in self.backing.listEventSubscriptions():
|
|
299
|
+
subId = str(record.get("subId", "") or "")
|
|
300
|
+
if subId:
|
|
301
|
+
self._records[subId] = copy.deepcopy(record)
|
|
302
|
+
|
|
303
|
+
def _backingEnabled(self) -> bool:
|
|
304
|
+
if self.backing is None:
|
|
305
|
+
return False
|
|
306
|
+
return bool(getattr(self.backing, "enabled", True))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class EventDeliveryBook:
|
|
310
|
+
"""Delivery attempt ledger for matched event subscriptions."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, *, backing: Any | None = None) -> None:
|
|
313
|
+
self.backing = backing
|
|
314
|
+
self._records: dict[str, dict[str, Any]] = {}
|
|
315
|
+
|
|
316
|
+
def createForEvent(self, event: dict[str, Any], subscription: dict[str, Any]) -> dict[str, Any]:
|
|
317
|
+
eventId = str(event.get("eventId", "") or "")
|
|
318
|
+
subId = str(subscription.get("subId", "") or "")
|
|
319
|
+
if eventId and subId:
|
|
320
|
+
current = self.getDelivery(_deliveryId(eventId, subId, 1))
|
|
321
|
+
if current.get("ok"):
|
|
322
|
+
return current
|
|
323
|
+
return self.createDelivery(
|
|
324
|
+
{
|
|
325
|
+
"eventId": eventId,
|
|
326
|
+
"subId": subId,
|
|
327
|
+
"clientId": subscription.get("clientId", ""),
|
|
328
|
+
"transport": subscription.get("transport", "poll"),
|
|
329
|
+
"endpoint": subscription.get("endpoint", ""),
|
|
330
|
+
"meta": {
|
|
331
|
+
"eventType": event.get("type", ""),
|
|
332
|
+
"subject": event.get("subject", ""),
|
|
333
|
+
"target": event.get("target", ""),
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def createDelivery(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
339
|
+
eventId = str(payload.get("eventId", "") or "").strip()
|
|
340
|
+
subId = str(payload.get("subId", "") or "").strip()
|
|
341
|
+
attempt = _attempt(payload.get("attempt", 1))
|
|
342
|
+
errors = _deliveryErrors(eventId=eventId, subId=subId, payload=payload)
|
|
343
|
+
deliveryId = str(payload.get("deliveryId", "") or _deliveryId(eventId, subId, attempt))
|
|
344
|
+
now = int(payload.get("createdAt", 0) or time.time())
|
|
345
|
+
current = self.getDelivery(deliveryId)
|
|
346
|
+
createdAt = current.get("delivery", {}).get("createdAt", now) if current.get("ok") else now
|
|
347
|
+
status = _deliveryStatus(payload.get("status", "queued"))
|
|
348
|
+
record = {
|
|
349
|
+
"deliveryId": deliveryId,
|
|
350
|
+
"eventId": eventId,
|
|
351
|
+
"subId": subId,
|
|
352
|
+
"clientId": str(payload.get("clientId", "") or ""),
|
|
353
|
+
"transport": _transport(payload.get("transport", "poll")),
|
|
354
|
+
"endpoint": str(payload.get("endpoint", "") or ""),
|
|
355
|
+
"status": status,
|
|
356
|
+
"attempt": attempt,
|
|
357
|
+
"claimedBy": str(payload.get("claimedBy", "") or ""),
|
|
358
|
+
"lastError": str(payload.get("lastError", "") or ""),
|
|
359
|
+
"meta": copy.deepcopy(payload.get("meta", {}) if isinstance(payload.get("meta"), dict) else {}),
|
|
360
|
+
"createdAt": int(createdAt or now),
|
|
361
|
+
"updatedAt": int(payload.get("updatedAt", now) or now),
|
|
362
|
+
"nextAttemptAt": _nextAttemptAt(payload, now) if status == "queued" else 0,
|
|
363
|
+
"claimedAt": int(payload.get("claimedAt", 0) or 0),
|
|
364
|
+
"leaseExpiresAt": int(payload.get("leaseExpiresAt", 0) or 0),
|
|
365
|
+
"deliveredAt": int(payload.get("deliveredAt", 0) or 0),
|
|
366
|
+
}
|
|
367
|
+
if errors:
|
|
368
|
+
return {"ok": False, "errors": errors, "delivery": record}
|
|
369
|
+
self._saveRecord(record)
|
|
370
|
+
return {"ok": True, "delivery": copy.deepcopy(record)}
|
|
371
|
+
|
|
372
|
+
def getDelivery(self, deliveryId: str) -> dict[str, Any]:
|
|
373
|
+
deliveryId = str(deliveryId or "")
|
|
374
|
+
record = self._records.get(deliveryId)
|
|
375
|
+
if record is None and self._backingEnabled() and hasattr(self.backing, "fetchEventDelivery"):
|
|
376
|
+
record = self.backing.fetchEventDelivery(deliveryId)
|
|
377
|
+
if record:
|
|
378
|
+
self._records[deliveryId] = copy.deepcopy(record)
|
|
379
|
+
if not record:
|
|
380
|
+
return {"ok": False, "error": "deliveryNotFound", "deliveryId": deliveryId}
|
|
381
|
+
return {"ok": True, "delivery": copy.deepcopy(record)}
|
|
382
|
+
|
|
383
|
+
def listDeliveries(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
384
|
+
self._loadBacking()
|
|
385
|
+
filters = filters or {}
|
|
386
|
+
records = self._filtered(filters)
|
|
387
|
+
records.sort(key=lambda item: (int(item.get("createdAt", 0) or 0), str(item.get("deliveryId", ""))), reverse=True)
|
|
388
|
+
limit = _limit(filters.get("limit", 100))
|
|
389
|
+
return {
|
|
390
|
+
"ok": True,
|
|
391
|
+
"deliveries": [copy.deepcopy(record) for record in records[:limit]],
|
|
392
|
+
"count": min(len(records), limit),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
def deliveryStats(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
396
|
+
self._loadBacking()
|
|
397
|
+
filters = filters or {}
|
|
398
|
+
now = int(filters.get("now", 0) or time.time())
|
|
399
|
+
records = self._filtered(filters)
|
|
400
|
+
ready = [record for record in records if record.get("status") == "queued" and _isDue(record, now)]
|
|
401
|
+
scheduled = [record for record in records if record.get("status") == "queued" and not _isDue(record, now)]
|
|
402
|
+
expired = [
|
|
403
|
+
record
|
|
404
|
+
for record in records
|
|
405
|
+
if record.get("status") == "claimed" and 0 < int(record.get("leaseExpiresAt", 0) or 0) <= now
|
|
406
|
+
]
|
|
407
|
+
activeClaims = [
|
|
408
|
+
record
|
|
409
|
+
for record in records
|
|
410
|
+
if record.get("status") == "claimed"
|
|
411
|
+
and (int(record.get("leaseExpiresAt", 0) or 0) <= 0 or int(record.get("leaseExpiresAt", 0) or 0) > now)
|
|
412
|
+
]
|
|
413
|
+
return {
|
|
414
|
+
"ok": True,
|
|
415
|
+
"stats": {
|
|
416
|
+
"total": len(records),
|
|
417
|
+
"ready": len(ready),
|
|
418
|
+
"scheduled": len(scheduled),
|
|
419
|
+
"expired": len(expired),
|
|
420
|
+
"activeClaims": len(activeClaims),
|
|
421
|
+
"byStatus": _countBy(records, "status"),
|
|
422
|
+
"byTransport": _countBy(records, "transport"),
|
|
423
|
+
"byClient": _countBy(records, "clientId"),
|
|
424
|
+
},
|
|
425
|
+
"count": len(records),
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
def readyDeliveries(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
429
|
+
self._loadBacking()
|
|
430
|
+
filters = filters or {}
|
|
431
|
+
now = int(filters.get("now", 0) or time.time())
|
|
432
|
+
records = self._claimable(self._claimFilters(filters), now)
|
|
433
|
+
records.sort(key=lambda item: (int(item.get("createdAt", 0) or 0), str(item.get("deliveryId", ""))))
|
|
434
|
+
limit = _limit(filters.get("limit", 100))
|
|
435
|
+
return {
|
|
436
|
+
"ok": True,
|
|
437
|
+
"deliveries": [copy.deepcopy(record) for record in records[:limit]],
|
|
438
|
+
"count": min(len(records), limit),
|
|
439
|
+
"total": len(records),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
def deliveryChain(self, deliveryId: str, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
443
|
+
current = self.getDelivery(deliveryId)
|
|
444
|
+
if not current.get("ok"):
|
|
445
|
+
return current
|
|
446
|
+
self._loadBacking()
|
|
447
|
+
filters = filters or {}
|
|
448
|
+
record = current["delivery"]
|
|
449
|
+
eventId = str(record.get("eventId", "") or "")
|
|
450
|
+
subId = str(record.get("subId", "") or "")
|
|
451
|
+
records = self._filtered({"eventId": eventId, "subId": subId})
|
|
452
|
+
records.sort(key=lambda item: (int(item.get("attempt", 1) or 1), int(item.get("createdAt", 0) or 0), str(item.get("deliveryId", ""))))
|
|
453
|
+
limit = _limit(filters.get("limit", 100))
|
|
454
|
+
root = records[0] if records else {}
|
|
455
|
+
latest = records[-1] if records else {}
|
|
456
|
+
attempts = [copy.deepcopy(item) for item in records[:limit]]
|
|
457
|
+
return {
|
|
458
|
+
"ok": True,
|
|
459
|
+
"deliveryId": str(deliveryId or ""),
|
|
460
|
+
"eventId": eventId,
|
|
461
|
+
"subId": subId,
|
|
462
|
+
"rootDeliveryId": str(root.get("deliveryId", "") or ""),
|
|
463
|
+
"latestDeliveryId": str(latest.get("deliveryId", "") or ""),
|
|
464
|
+
"deliveries": attempts,
|
|
465
|
+
"count": min(len(records), limit),
|
|
466
|
+
"total": len(records),
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
def claimNext(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
470
|
+
payload = payload or {}
|
|
471
|
+
filters = self._claimFilters(payload)
|
|
472
|
+
if self._backingEnabled() and hasattr(self.backing, "claimEventDelivery"):
|
|
473
|
+
claimed = self.backing.claimEventDelivery(payload)
|
|
474
|
+
if claimed:
|
|
475
|
+
deliveryId = str(claimed.get("deliveryId", "") or "")
|
|
476
|
+
if deliveryId:
|
|
477
|
+
self._records[deliveryId] = copy.deepcopy(claimed)
|
|
478
|
+
return {"ok": True, "delivery": copy.deepcopy(claimed)}
|
|
479
|
+
return {"ok": False, "error": "deliveryNotFound", "filters": {key: value for key, value in filters.items() if value}}
|
|
480
|
+
else:
|
|
481
|
+
self._loadBacking()
|
|
482
|
+
now = int(time.time())
|
|
483
|
+
records = self._claimable(filters, now)
|
|
484
|
+
records.sort(key=lambda item: (int(item.get("createdAt", 0) or 0), str(item.get("deliveryId", ""))))
|
|
485
|
+
if not records:
|
|
486
|
+
return {"ok": False, "error": "deliveryNotFound", "filters": {key: value for key, value in filters.items() if value}}
|
|
487
|
+
claimedBy = str(payload.get("claimedBy", payload.get("workerId", "")) or "")
|
|
488
|
+
return self.updateDelivery(
|
|
489
|
+
records[0]["deliveryId"],
|
|
490
|
+
{
|
|
491
|
+
"status": "claimed",
|
|
492
|
+
"claimedBy": claimedBy,
|
|
493
|
+
"leaseExpiresAt": _leaseExpiresAt(payload, now),
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def claimBatch(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
498
|
+
payload = payload or {}
|
|
499
|
+
limit = _limit(payload.get("limit", payload.get("count", 10)))
|
|
500
|
+
claimed: list[dict[str, Any]] = []
|
|
501
|
+
if self._backingEnabled() and hasattr(self.backing, "claimEventDelivery"):
|
|
502
|
+
for _ in range(limit):
|
|
503
|
+
result = self.backing.claimEventDelivery(payload)
|
|
504
|
+
if not result:
|
|
505
|
+
break
|
|
506
|
+
deliveryId = str(result.get("deliveryId", "") or "")
|
|
507
|
+
if deliveryId:
|
|
508
|
+
self._records[deliveryId] = copy.deepcopy(result)
|
|
509
|
+
claimed.append(copy.deepcopy(result))
|
|
510
|
+
return {"ok": True, "deliveries": claimed, "count": len(claimed), "total": len(claimed)}
|
|
511
|
+
self._loadBacking()
|
|
512
|
+
now = int(time.time())
|
|
513
|
+
records = self._claimable(self._claimFilters(payload), now)
|
|
514
|
+
records.sort(key=lambda item: (int(item.get("createdAt", 0) or 0), str(item.get("deliveryId", ""))))
|
|
515
|
+
for record in records[:limit]:
|
|
516
|
+
result = self.updateDelivery(
|
|
517
|
+
str(record.get("deliveryId", "") or ""),
|
|
518
|
+
{
|
|
519
|
+
"status": "claimed",
|
|
520
|
+
"claimedBy": str(payload.get("claimedBy", payload.get("workerId", "")) or ""),
|
|
521
|
+
"leaseExpiresAt": _leaseExpiresAt(payload, now),
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
if result.get("ok") and isinstance(result.get("delivery"), dict):
|
|
525
|
+
claimed.append(copy.deepcopy(result["delivery"]))
|
|
526
|
+
return {"ok": True, "deliveries": claimed, "count": len(claimed), "total": len(records)}
|
|
527
|
+
|
|
528
|
+
def retryDelivery(self, deliveryId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
529
|
+
current = self.getDelivery(deliveryId)
|
|
530
|
+
if not current.get("ok"):
|
|
531
|
+
return current
|
|
532
|
+
payload = payload or {}
|
|
533
|
+
record = current["delivery"]
|
|
534
|
+
if record.get("status") not in {"failed", "canceled"}:
|
|
535
|
+
return {"ok": False, "error": "invalid:retryStatus", "delivery": copy.deepcopy(record)}
|
|
536
|
+
meta = record.get("meta", {}) if isinstance(record.get("meta"), dict) else {}
|
|
537
|
+
if isinstance(payload.get("meta"), dict):
|
|
538
|
+
meta = {**meta, **payload["meta"]}
|
|
539
|
+
meta["parentDeliveryId"] = str(record.get("deliveryId", "") or deliveryId)
|
|
540
|
+
return self.createDelivery(
|
|
541
|
+
{
|
|
542
|
+
"eventId": record.get("eventId", ""),
|
|
543
|
+
"subId": record.get("subId", ""),
|
|
544
|
+
"clientId": payload.get("clientId", record.get("clientId", "")),
|
|
545
|
+
"transport": payload.get("transport", record.get("transport", "poll")),
|
|
546
|
+
"endpoint": payload.get("endpoint", record.get("endpoint", "")),
|
|
547
|
+
"status": "queued",
|
|
548
|
+
"attempt": _attempt(payload.get("attempt", int(record.get("attempt", 1) or 1) + 1)),
|
|
549
|
+
"nextAttemptAt": payload.get("nextAttemptAt", 0),
|
|
550
|
+
"delaySeconds": payload.get("delaySeconds", ""),
|
|
551
|
+
"meta": meta,
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def failDelivery(self, deliveryId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
556
|
+
current = self.getDelivery(deliveryId)
|
|
557
|
+
if not current.get("ok"):
|
|
558
|
+
return current
|
|
559
|
+
payload = payload or {}
|
|
560
|
+
record = current["delivery"]
|
|
561
|
+
if record.get("status") in {"delivered", "canceled"}:
|
|
562
|
+
return {"ok": False, "error": "invalid:failStatus", "delivery": copy.deepcopy(record)}
|
|
563
|
+
workerId = str(payload.get("workerId", payload.get("claimedBy", "")) or "")
|
|
564
|
+
claimedBy = str(record.get("claimedBy", "") or "")
|
|
565
|
+
if record.get("status") == "claimed" and workerId and claimedBy and workerId != claimedBy:
|
|
566
|
+
return {"ok": False, "error": "invalid:claimOwner", "delivery": copy.deepcopy(record)}
|
|
567
|
+
if record.get("status") == "failed":
|
|
568
|
+
failed = copy.deepcopy(record)
|
|
569
|
+
else:
|
|
570
|
+
updates: dict[str, Any] = {"status": "failed"}
|
|
571
|
+
if "lastError" in payload:
|
|
572
|
+
updates["lastError"] = str(payload.get("lastError", "") or "")
|
|
573
|
+
if isinstance(payload.get("meta"), dict):
|
|
574
|
+
updates["meta"] = payload["meta"]
|
|
575
|
+
failedResult = self.updateDelivery(deliveryId, updates)
|
|
576
|
+
if not failedResult.get("ok"):
|
|
577
|
+
return failedResult
|
|
578
|
+
failed = failedResult["delivery"]
|
|
579
|
+
result: dict[str, Any] = {"ok": True, "delivery": copy.deepcopy(failed)}
|
|
580
|
+
if not _retryEnabled(payload.get("retry", True)):
|
|
581
|
+
result["retry"] = {"scheduled": False, "reason": "disabled"}
|
|
582
|
+
return result
|
|
583
|
+
maxAttempts = _maxAttempts(payload.get("maxAttempts", 3))
|
|
584
|
+
if int(failed.get("attempt", 1) or 1) >= maxAttempts:
|
|
585
|
+
result["retry"] = {"scheduled": False, "reason": "maxAttempts", "maxAttempts": maxAttempts}
|
|
586
|
+
return result
|
|
587
|
+
nextAttempt = _attempt(payload.get("attempt", int(failed.get("attempt", 1) or 1) + 1))
|
|
588
|
+
if nextAttempt > maxAttempts:
|
|
589
|
+
result["retry"] = {"scheduled": False, "reason": "maxAttempts", "maxAttempts": maxAttempts}
|
|
590
|
+
return result
|
|
591
|
+
retryPayload = {
|
|
592
|
+
key: payload[key]
|
|
593
|
+
for key in ("clientId", "transport", "endpoint", "attempt", "nextAttemptAt", "delaySeconds")
|
|
594
|
+
if key in payload
|
|
595
|
+
}
|
|
596
|
+
retryMeta = payload.get("retryMeta", {})
|
|
597
|
+
if isinstance(retryMeta, dict):
|
|
598
|
+
retryPayload["meta"] = retryMeta
|
|
599
|
+
retried = self.retryDelivery(deliveryId, retryPayload)
|
|
600
|
+
if not retried.get("ok"):
|
|
601
|
+
return {"ok": False, "error": retried.get("error", "retryFailed"), "delivery": copy.deepcopy(failed), "retry": retried}
|
|
602
|
+
result["retry"] = {"scheduled": True, "delivery": retried["delivery"], "maxAttempts": maxAttempts}
|
|
603
|
+
return result
|
|
604
|
+
|
|
605
|
+
def completeDelivery(self, deliveryId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
606
|
+
current = self.getDelivery(deliveryId)
|
|
607
|
+
if not current.get("ok"):
|
|
608
|
+
return current
|
|
609
|
+
payload = payload or {}
|
|
610
|
+
record = current["delivery"]
|
|
611
|
+
if record.get("status") == "delivered":
|
|
612
|
+
return {"ok": True, "delivery": copy.deepcopy(record)}
|
|
613
|
+
if record.get("status") != "claimed":
|
|
614
|
+
return {"ok": False, "error": "invalid:completeStatus", "delivery": copy.deepcopy(record)}
|
|
615
|
+
workerId = str(payload.get("workerId", payload.get("claimedBy", "")) or "")
|
|
616
|
+
claimedBy = str(record.get("claimedBy", "") or "")
|
|
617
|
+
if workerId and claimedBy and workerId != claimedBy:
|
|
618
|
+
return {"ok": False, "error": "invalid:claimOwner", "delivery": copy.deepcopy(record)}
|
|
619
|
+
updates: dict[str, Any] = {"status": "delivered"}
|
|
620
|
+
if "deliveredAt" in payload:
|
|
621
|
+
updates["deliveredAt"] = int(payload.get("deliveredAt", 0) or 0)
|
|
622
|
+
if "lastError" in payload:
|
|
623
|
+
updates["lastError"] = str(payload.get("lastError", "") or "")
|
|
624
|
+
if isinstance(payload.get("meta"), dict):
|
|
625
|
+
updates["meta"] = payload["meta"]
|
|
626
|
+
return self.updateDelivery(deliveryId, updates)
|
|
627
|
+
|
|
628
|
+
def cancelDelivery(self, deliveryId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
629
|
+
current = self.getDelivery(deliveryId)
|
|
630
|
+
if not current.get("ok"):
|
|
631
|
+
return current
|
|
632
|
+
payload = payload or {}
|
|
633
|
+
record = current["delivery"]
|
|
634
|
+
if record.get("status") == "canceled":
|
|
635
|
+
return {"ok": True, "delivery": copy.deepcopy(record)}
|
|
636
|
+
if record.get("status") == "delivered":
|
|
637
|
+
return {"ok": False, "error": "invalid:cancelStatus", "delivery": copy.deepcopy(record)}
|
|
638
|
+
workerId = str(payload.get("workerId", payload.get("claimedBy", "")) or "")
|
|
639
|
+
claimedBy = str(record.get("claimedBy", "") or "")
|
|
640
|
+
if record.get("status") == "claimed" and workerId and claimedBy and workerId != claimedBy:
|
|
641
|
+
return {"ok": False, "error": "invalid:claimOwner", "delivery": copy.deepcopy(record)}
|
|
642
|
+
updates: dict[str, Any] = {"status": "canceled"}
|
|
643
|
+
if "lastError" in payload:
|
|
644
|
+
updates["lastError"] = str(payload.get("lastError", "") or "")
|
|
645
|
+
elif "reason" in payload:
|
|
646
|
+
updates["lastError"] = str(payload.get("reason", "") or "")
|
|
647
|
+
if isinstance(payload.get("meta"), dict):
|
|
648
|
+
updates["meta"] = payload["meta"]
|
|
649
|
+
return self.updateDelivery(deliveryId, updates)
|
|
650
|
+
|
|
651
|
+
def renewLease(self, deliveryId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
652
|
+
current = self.getDelivery(deliveryId)
|
|
653
|
+
if not current.get("ok"):
|
|
654
|
+
return current
|
|
655
|
+
payload = payload or {}
|
|
656
|
+
record = current["delivery"]
|
|
657
|
+
if record.get("status") != "claimed":
|
|
658
|
+
return {"ok": False, "error": "invalid:leaseStatus", "delivery": copy.deepcopy(record)}
|
|
659
|
+
claimedBy = str(payload.get("claimedBy", payload.get("workerId", "")) or "")
|
|
660
|
+
if claimedBy and claimedBy != str(record.get("claimedBy", "") or ""):
|
|
661
|
+
return {"ok": False, "error": "invalid:leaseOwner", "delivery": copy.deepcopy(record)}
|
|
662
|
+
now = int(time.time())
|
|
663
|
+
return self.updateDelivery(deliveryId, {"leaseExpiresAt": _leaseExpiresAt(payload, now)})
|
|
664
|
+
|
|
665
|
+
def releaseDelivery(self, deliveryId: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
666
|
+
current = self.getDelivery(deliveryId)
|
|
667
|
+
if not current.get("ok"):
|
|
668
|
+
return current
|
|
669
|
+
payload = payload or {}
|
|
670
|
+
record = current["delivery"]
|
|
671
|
+
if record.get("status") != "claimed":
|
|
672
|
+
return {"ok": False, "error": "invalid:releaseStatus", "delivery": copy.deepcopy(record)}
|
|
673
|
+
updates: dict[str, Any] = {
|
|
674
|
+
"status": "queued",
|
|
675
|
+
"claimedBy": "",
|
|
676
|
+
"claimedAt": 0,
|
|
677
|
+
"leaseExpiresAt": 0,
|
|
678
|
+
}
|
|
679
|
+
if payload.get("lastError"):
|
|
680
|
+
updates["lastError"] = str(payload.get("lastError", "") or "")
|
|
681
|
+
if isinstance(payload.get("meta"), dict):
|
|
682
|
+
updates["meta"] = payload["meta"]
|
|
683
|
+
if payload.get("nextAttemptAt") or payload.get("delaySeconds"):
|
|
684
|
+
updates["nextAttemptAt"] = _nextAttemptAt(payload, int(time.time()))
|
|
685
|
+
return self.updateDelivery(deliveryId, updates)
|
|
686
|
+
|
|
687
|
+
def sweepExpiredLeases(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
688
|
+
payload = payload or {}
|
|
689
|
+
if self._backingEnabled() and hasattr(self.backing, "sweepEventDeliveryLeases"):
|
|
690
|
+
records = self.backing.sweepEventDeliveryLeases(payload)
|
|
691
|
+
for record in records:
|
|
692
|
+
deliveryId = str(record.get("deliveryId", "") or "")
|
|
693
|
+
if deliveryId:
|
|
694
|
+
self._records[deliveryId] = copy.deepcopy(record)
|
|
695
|
+
return {"ok": True, "deliveries": [copy.deepcopy(record) for record in records], "count": len(records)}
|
|
696
|
+
self._loadBacking()
|
|
697
|
+
now = int(payload.get("now", 0) or time.time())
|
|
698
|
+
records = self._expiredLeases(self._claimFilters(payload), now)
|
|
699
|
+
limit = _limit(payload.get("limit", 100))
|
|
700
|
+
swept: list[dict[str, Any]] = []
|
|
701
|
+
for record in records[:limit]:
|
|
702
|
+
result = self.releaseDelivery(
|
|
703
|
+
str(record.get("deliveryId", "") or ""),
|
|
704
|
+
{"lastError": str(payload.get("lastError", record.get("lastError", "")) or "")},
|
|
705
|
+
)
|
|
706
|
+
if result.get("ok") and isinstance(result.get("delivery"), dict):
|
|
707
|
+
swept.append(copy.deepcopy(result["delivery"]))
|
|
708
|
+
return {"ok": True, "deliveries": swept, "count": len(swept)}
|
|
709
|
+
|
|
710
|
+
def updateDelivery(self, deliveryId: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
711
|
+
current = self.getDelivery(deliveryId)
|
|
712
|
+
if not current.get("ok"):
|
|
713
|
+
return current
|
|
714
|
+
now = int(time.time())
|
|
715
|
+
record = current["delivery"]
|
|
716
|
+
status = _deliveryStatus(updates.get("status", record.get("status", "queued")))
|
|
717
|
+
claimedAt = int(updates.get("claimedAt", record.get("claimedAt", 0)) or 0)
|
|
718
|
+
leaseExpiresAt = int(updates.get("leaseExpiresAt", record.get("leaseExpiresAt", 0)) or 0)
|
|
719
|
+
nextAttemptAt = int(updates.get("nextAttemptAt", record.get("nextAttemptAt", 0)) or 0)
|
|
720
|
+
deliveredAt = int(updates.get("deliveredAt", record.get("deliveredAt", 0)) or 0)
|
|
721
|
+
if "delaySeconds" in updates:
|
|
722
|
+
nextAttemptAt = _nextAttemptAt(updates, now)
|
|
723
|
+
if status == "claimed" and not claimedAt:
|
|
724
|
+
claimedAt = now
|
|
725
|
+
if status == "claimed" and not leaseExpiresAt:
|
|
726
|
+
leaseExpiresAt = _leaseExpiresAt(updates, now)
|
|
727
|
+
if status != "claimed":
|
|
728
|
+
leaseExpiresAt = 0
|
|
729
|
+
if status != "queued":
|
|
730
|
+
nextAttemptAt = 0
|
|
731
|
+
if status == "delivered" and not deliveredAt:
|
|
732
|
+
deliveredAt = now
|
|
733
|
+
meta = record.get("meta", {}) if isinstance(record.get("meta"), dict) else {}
|
|
734
|
+
if isinstance(updates.get("meta"), dict):
|
|
735
|
+
meta = {**meta, **updates["meta"]}
|
|
736
|
+
merged = {
|
|
737
|
+
**record,
|
|
738
|
+
"status": status,
|
|
739
|
+
"attempt": _attempt(updates.get("attempt", record.get("attempt", 1))),
|
|
740
|
+
"claimedBy": str(updates.get("claimedBy", record.get("claimedBy", "")) or ""),
|
|
741
|
+
"lastError": str(updates.get("lastError", record.get("lastError", "")) or ""),
|
|
742
|
+
"meta": meta,
|
|
743
|
+
"updatedAt": int(updates.get("updatedAt", now) or now),
|
|
744
|
+
"nextAttemptAt": nextAttemptAt,
|
|
745
|
+
"claimedAt": claimedAt,
|
|
746
|
+
"leaseExpiresAt": leaseExpiresAt,
|
|
747
|
+
"deliveredAt": deliveredAt,
|
|
748
|
+
}
|
|
749
|
+
errors = _deliveryErrors(eventId=merged["eventId"], subId=merged["subId"], payload=merged)
|
|
750
|
+
if errors:
|
|
751
|
+
return {"ok": False, "errors": errors, "delivery": merged}
|
|
752
|
+
self._saveRecord(merged)
|
|
753
|
+
return {"ok": True, "delivery": copy.deepcopy(merged)}
|
|
754
|
+
|
|
755
|
+
def _saveRecord(self, record: dict[str, Any]) -> None:
|
|
756
|
+
deliveryId = str(record.get("deliveryId", "") or "")
|
|
757
|
+
if not deliveryId:
|
|
758
|
+
return
|
|
759
|
+
self._records[deliveryId] = copy.deepcopy(record)
|
|
760
|
+
if self._backingEnabled() and hasattr(self.backing, "upsertEventDelivery"):
|
|
761
|
+
self.backing.upsertEventDelivery(record)
|
|
762
|
+
|
|
763
|
+
def _loadBacking(self) -> None:
|
|
764
|
+
if self.backing is None or not self._backingEnabled() or not hasattr(self.backing, "listEventDeliveries"):
|
|
765
|
+
return
|
|
766
|
+
for record in self.backing.listEventDeliveries():
|
|
767
|
+
deliveryId = str(record.get("deliveryId", "") or "")
|
|
768
|
+
if deliveryId:
|
|
769
|
+
self._records[deliveryId] = copy.deepcopy(record)
|
|
770
|
+
|
|
771
|
+
def _backingEnabled(self) -> bool:
|
|
772
|
+
if self.backing is None:
|
|
773
|
+
return False
|
|
774
|
+
return bool(getattr(self.backing, "enabled", True))
|
|
775
|
+
|
|
776
|
+
def _filtered(self, filters: dict[str, Any]) -> list[dict[str, Any]]:
|
|
777
|
+
eventId = str(filters.get("eventId", "") or "")
|
|
778
|
+
subId = str(filters.get("subId", "") or "")
|
|
779
|
+
clientId = str(filters.get("clientId", "") or "")
|
|
780
|
+
status = str(filters.get("status", "") or "")
|
|
781
|
+
transport = str(filters.get("transport", "") or "")
|
|
782
|
+
records = list(self._records.values())
|
|
783
|
+
if eventId:
|
|
784
|
+
records = [record for record in records if record.get("eventId") == eventId]
|
|
785
|
+
if subId:
|
|
786
|
+
records = [record for record in records if record.get("subId") == subId]
|
|
787
|
+
if clientId:
|
|
788
|
+
records = [record for record in records if record.get("clientId") == clientId]
|
|
789
|
+
if status:
|
|
790
|
+
records = [record for record in records if record.get("status") == status]
|
|
791
|
+
if transport:
|
|
792
|
+
records = [record for record in records if record.get("transport") == transport]
|
|
793
|
+
return [copy.deepcopy(record) for record in records]
|
|
794
|
+
|
|
795
|
+
@staticmethod
|
|
796
|
+
def _claimFilters(payload: dict[str, Any]) -> dict[str, Any]:
|
|
797
|
+
return {
|
|
798
|
+
"eventId": payload.get("eventId", ""),
|
|
799
|
+
"subId": payload.get("subId", ""),
|
|
800
|
+
"clientId": payload.get("clientId", ""),
|
|
801
|
+
"transport": payload.get("transport", ""),
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
def _claimable(self, filters: dict[str, Any], now: int) -> list[dict[str, Any]]:
|
|
805
|
+
records = self._filtered(filters)
|
|
806
|
+
return [
|
|
807
|
+
record
|
|
808
|
+
for record in records
|
|
809
|
+
if (record.get("status") == "queued" and _isDue(record, now))
|
|
810
|
+
or (record.get("status") == "claimed" and 0 < int(record.get("leaseExpiresAt", 0) or 0) <= now)
|
|
811
|
+
]
|
|
812
|
+
|
|
813
|
+
def _expiredLeases(self, filters: dict[str, Any], now: int) -> list[dict[str, Any]]:
|
|
814
|
+
records = self._filtered(filters)
|
|
815
|
+
records = [
|
|
816
|
+
record
|
|
817
|
+
for record in records
|
|
818
|
+
if record.get("status") == "claimed" and 0 < int(record.get("leaseExpiresAt", 0) or 0) <= now
|
|
819
|
+
]
|
|
820
|
+
records.sort(key=lambda item: (int(item.get("leaseExpiresAt", 0) or 0), str(item.get("deliveryId", ""))))
|
|
821
|
+
return records
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _stableId(eventType: str, payload: dict[str, Any], createdAt: int) -> str:
|
|
825
|
+
base = {
|
|
826
|
+
"type": eventType,
|
|
827
|
+
"subject": str(payload.get("subject", "") or ""),
|
|
828
|
+
"source": str(payload.get("source", "picux") or "picux"),
|
|
829
|
+
"target": str(payload.get("target", "") or ""),
|
|
830
|
+
"payload": payload.get("payload", {}) if isinstance(payload.get("payload"), dict) else {},
|
|
831
|
+
"createdAt": int(createdAt or 0),
|
|
832
|
+
}
|
|
833
|
+
digest = hashlib.sha256(json.dumps(base, ensure_ascii=True, sort_keys=True).encode("utf-8")).hexdigest()
|
|
834
|
+
return f"evt_{digest[:24]}"
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _status(value: Any) -> str:
|
|
838
|
+
status = str(value or "queued")
|
|
839
|
+
return status if status in EVENT_STATUSES else "queued"
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _subStatus(value: Any) -> str:
|
|
843
|
+
status = str(value or "active")
|
|
844
|
+
return status if status in SUBSCRIPTION_STATUSES else "active"
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _transport(value: Any) -> str:
|
|
848
|
+
transport = str(value or "poll")
|
|
849
|
+
return transport if transport in SUBSCRIPTION_TRANSPORTS else "poll"
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _deliveryStatus(value: Any) -> str:
|
|
853
|
+
status = str(value or "queued")
|
|
854
|
+
return status if status in DELIVERY_STATUSES else "queued"
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _cleanList(value: Any) -> list[str]:
|
|
858
|
+
if isinstance(value, str):
|
|
859
|
+
value = [value]
|
|
860
|
+
if not isinstance(value, (list, tuple, set)):
|
|
861
|
+
return []
|
|
862
|
+
cleaned: list[str] = []
|
|
863
|
+
for item in value:
|
|
864
|
+
text = str(item or "").strip()
|
|
865
|
+
if text and text not in cleaned:
|
|
866
|
+
cleaned.append(text)
|
|
867
|
+
return cleaned
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _subscriptionErrors(*, clientId: str, eventTypes: list[str], payload: dict[str, Any]) -> list[str]:
|
|
871
|
+
errors: list[str] = []
|
|
872
|
+
if not clientId:
|
|
873
|
+
errors.append("missing:clientId")
|
|
874
|
+
if not eventTypes:
|
|
875
|
+
errors.append("missing:eventTypes")
|
|
876
|
+
if str(payload.get("status", "active") or "active") not in SUBSCRIPTION_STATUSES:
|
|
877
|
+
errors.append("invalid:status")
|
|
878
|
+
if str(payload.get("transport", "poll") or "poll") not in SUBSCRIPTION_TRANSPORTS:
|
|
879
|
+
errors.append("invalid:transport")
|
|
880
|
+
return errors
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _subscriptionId(clientId: str, eventTypes: list[str], payload: dict[str, Any]) -> str:
|
|
884
|
+
base = {
|
|
885
|
+
"clientId": clientId,
|
|
886
|
+
"eventTypes": eventTypes,
|
|
887
|
+
"target": str(payload.get("target", clientId) or ""),
|
|
888
|
+
"transport": str(payload.get("transport", "poll") or "poll"),
|
|
889
|
+
"endpoint": str(payload.get("endpoint", "") or ""),
|
|
890
|
+
}
|
|
891
|
+
digest = hashlib.sha256(json.dumps(base, ensure_ascii=True, sort_keys=True).encode("utf-8")).hexdigest()
|
|
892
|
+
return f"sub_{digest[:24]}"
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _deliveryId(eventId: str, subId: str, attempt: int) -> str:
|
|
896
|
+
base = {"eventId": eventId, "subId": subId, "attempt": int(attempt or 1)}
|
|
897
|
+
digest = hashlib.sha256(json.dumps(base, ensure_ascii=True, sort_keys=True).encode("utf-8")).hexdigest()
|
|
898
|
+
return f"delv_{digest[:24]}"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _deliveryErrors(*, eventId: str, subId: str, payload: dict[str, Any]) -> list[str]:
|
|
902
|
+
errors: list[str] = []
|
|
903
|
+
if not eventId:
|
|
904
|
+
errors.append("missing:eventId")
|
|
905
|
+
if not subId:
|
|
906
|
+
errors.append("missing:subId")
|
|
907
|
+
if str(payload.get("status", "queued") or "queued") not in DELIVERY_STATUSES:
|
|
908
|
+
errors.append("invalid:status")
|
|
909
|
+
if str(payload.get("transport", "poll") or "poll") not in SUBSCRIPTION_TRANSPORTS:
|
|
910
|
+
errors.append("invalid:transport")
|
|
911
|
+
if _attempt(payload.get("attempt", 1)) < 1:
|
|
912
|
+
errors.append("invalid:attempt")
|
|
913
|
+
return errors
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _attempt(value: Any) -> int:
|
|
917
|
+
try:
|
|
918
|
+
parsed = int(value)
|
|
919
|
+
except (TypeError, ValueError):
|
|
920
|
+
return 1
|
|
921
|
+
return max(1, parsed)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _leaseSeconds(value: Any) -> int:
|
|
925
|
+
try:
|
|
926
|
+
parsed = int(value)
|
|
927
|
+
except (TypeError, ValueError):
|
|
928
|
+
return 300
|
|
929
|
+
return max(1, min(parsed, 86400))
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _leaseExpiresAt(payload: dict[str, Any], now: int) -> int:
|
|
933
|
+
explicit = int(payload.get("leaseExpiresAt", 0) or 0)
|
|
934
|
+
if explicit:
|
|
935
|
+
return explicit
|
|
936
|
+
return now + _leaseSeconds(payload.get("leaseSeconds", 300))
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _delaySeconds(value: Any) -> int:
|
|
940
|
+
try:
|
|
941
|
+
parsed = int(value)
|
|
942
|
+
except (TypeError, ValueError):
|
|
943
|
+
return 0
|
|
944
|
+
return max(0, min(parsed, 2592000))
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _retryEnabled(value: Any) -> bool:
|
|
948
|
+
if isinstance(value, bool):
|
|
949
|
+
return value
|
|
950
|
+
return str(value).strip().lower() not in {"0", "false", "no", "off", "disabled"}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _maxAttempts(value: Any) -> int:
|
|
954
|
+
try:
|
|
955
|
+
parsed = int(value)
|
|
956
|
+
except (TypeError, ValueError):
|
|
957
|
+
return 3
|
|
958
|
+
return max(1, min(parsed, 100))
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _nextAttemptAt(payload: dict[str, Any], now: int) -> int:
|
|
962
|
+
explicit = int(payload.get("nextAttemptAt", 0) or 0)
|
|
963
|
+
if explicit:
|
|
964
|
+
return explicit
|
|
965
|
+
delay = _delaySeconds(payload.get("delaySeconds", 0))
|
|
966
|
+
return now + delay if delay else 0
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _isDue(record: dict[str, Any], now: int) -> bool:
|
|
970
|
+
nextAttemptAt = int(record.get("nextAttemptAt", 0) or 0)
|
|
971
|
+
return nextAttemptAt <= 0 or nextAttemptAt <= now
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _countBy(records: list[dict[str, Any]], key: str) -> dict[str, int]:
|
|
975
|
+
counts: dict[str, int] = {}
|
|
976
|
+
for record in records:
|
|
977
|
+
value = str(record.get(key, "") or "")
|
|
978
|
+
if not value:
|
|
979
|
+
continue
|
|
980
|
+
counts[value] = counts.get(value, 0) + 1
|
|
981
|
+
return counts
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _limit(value: Any) -> int:
|
|
985
|
+
try:
|
|
986
|
+
parsed = int(value)
|
|
987
|
+
except (TypeError, ValueError):
|
|
988
|
+
return 100
|
|
989
|
+
return max(1, min(parsed, 500))
|