forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdurable_queue_policy.py
More file actions
193 lines (157 loc) · 5.97 KB
/
Copy pathdurable_queue_policy.py
File metadata and controls
193 lines (157 loc) · 5.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""I/O-free durable-queue policy: outcomes, attempt budget, adopt, drain, age.
Async callers (pusher finalization) import this module instead of
``database.durable_queue`` so the async-blocker gate does not treat a pure
decision as Firestore I/O. Firestore-touching drain helpers and redrive live
in ``database.durable_queue``.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Awaitable, Callable, Generic, Iterable, List, Optional, Sequence, TypeVar
T = TypeVar('T')
_MAX_ERROR_TEXT = 2000
_MAX_BACKOFF_EXPONENT = 30
class OutcomeKind(str, Enum):
ACK = 'ack'
RETRY = 'retry'
REJECT = 'reject'
@dataclass(frozen=True)
class ProcessOutcome:
kind: OutcomeKind
error_text: Optional[str] = None
reason: Optional[str] = None
@staticmethod
def ack() -> 'ProcessOutcome':
return ProcessOutcome(OutcomeKind.ACK)
@staticmethod
def retry(error_text: str, reason: str = 'retryable') -> 'ProcessOutcome':
return ProcessOutcome(OutcomeKind.RETRY, error_text=_bound_error(error_text), reason=reason)
@staticmethod
def reject(error_text: str, reason: str) -> 'ProcessOutcome':
return ProcessOutcome(OutcomeKind.REJECT, error_text=_bound_error(error_text), reason=reason)
@dataclass(frozen=True)
class QueuePolicy:
max_attempts: int = 5
base_backoff_seconds: float = 1.0
max_backoff_seconds: float = 1800.0
@dataclass(frozen=True)
class AttemptDecision:
terminal: bool
attempt_count: int
available_at: Optional[datetime]
error_text: str
reason: str
status: str
@dataclass(frozen=True)
class EnqueueDecision:
adopted: bool
@dataclass(frozen=True)
class IsolatedResult(Generic[T]):
item: T
outcome: ProcessOutcome
raised: bool
def _bound_error(text: str) -> str:
return text[:_MAX_ERROR_TEXT]
def backoff_seconds(policy: QueuePolicy, attempt_count: int) -> float:
exponent = min(max(attempt_count - 1, 0), _MAX_BACKOFF_EXPONENT)
return min(policy.max_backoff_seconds, policy.base_backoff_seconds * (2**exponent))
def decide_attempt(
*,
attempt_count: int,
outcome: ProcessOutcome,
policy: QueuePolicy,
now: datetime,
) -> AttemptDecision:
"""Map one failure onto retry-or-dead-letter. ``attempt_count`` includes this try."""
if outcome.kind == OutcomeKind.ACK:
raise ValueError('ack is not an attempt failure')
if attempt_count < 1:
raise ValueError('attempt_count must be at least 1')
error_text = _bound_error(outcome.error_text or '')
reason = outcome.reason or 'unspecified'
terminal = outcome.kind == OutcomeKind.REJECT or attempt_count >= policy.max_attempts
if terminal:
return AttemptDecision(
terminal=True,
attempt_count=attempt_count,
available_at=None,
error_text=error_text,
reason=reason,
status='dead_letter',
)
return AttemptDecision(
terminal=False,
attempt_count=attempt_count,
available_at=now + timedelta(seconds=backoff_seconds(policy, attempt_count)),
error_text=error_text,
reason=reason,
status='retrying',
)
def adopt_on_identity(*, existing_id: Optional[str], item_id: str) -> EnqueueDecision:
"""Collision on a stable item id is adopted. Never raise, never key on payload."""
if not item_id:
raise ValueError('item_id is required')
if existing_id is None:
return EnqueueDecision(adopted=False)
if existing_id != item_id:
raise ValueError('enqueue identity mismatch')
return EnqueueDecision(adopted=True)
def ready_sort_key(
*,
priority: int = 0,
available_at: object = None,
created_at: object = None,
item_id: str = '',
enable_priority: bool = False,
) -> tuple[object, ...]:
if enable_priority:
return (priority, available_at, created_at, item_id)
return (available_at, created_at, item_id)
def oldest_ready_age_seconds(created_ats: Sequence[Optional[datetime]], *, now: datetime) -> Optional[float]:
ready: List[datetime] = []
for created_at in created_ats:
if created_at is None:
continue
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
ready.append(created_at)
if not ready:
return None
observed = now if now.tzinfo is not None else now.replace(tzinfo=timezone.utc)
return max(0.0, (observed - min(ready)).total_seconds())
def drain_isolated(
items: Iterable[T],
process_one: Callable[[T], object],
) -> List[IsolatedResult[T]]:
"""Process each item independently. A raised or rejected item never stops the rest."""
results: List[IsolatedResult[T]] = []
for item in items:
raised = False
try:
outcome = process_one(item)
except Exception as exc:
raised = True
outcome = ProcessOutcome.reject(str(exc), reason='processor_exception')
if not isinstance(outcome, ProcessOutcome):
raised = True
outcome = ProcessOutcome.reject('processor returned a non-outcome', reason='invalid_outcome')
results.append(IsolatedResult(item=item, outcome=outcome, raised=raised))
return results
async def drain_isolated_async(
items: Iterable[T],
process_one: Callable[[T], Awaitable[object]],
) -> List[IsolatedResult[T]]:
results: List[IsolatedResult[T]] = []
for item in items:
raised = False
try:
outcome = await process_one(item)
except Exception as exc:
raised = True
outcome = ProcessOutcome.reject(str(exc), reason='processor_exception')
if not isinstance(outcome, ProcessOutcome):
raised = True
outcome = ProcessOutcome.reject('processor returned a non-outcome', reason='invalid_outcome')
results.append(IsolatedResult(item=item, outcome=outcome, raised=raised))
return results