refresh-engine 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.
- refresh_engine/__init__.py +169 -0
- refresh_engine/api/__init__.py +43 -0
- refresh_engine/api/abc.py +176 -0
- refresh_engine/api/engine.py +468 -0
- refresh_engine/api/protocols.py +89 -0
- refresh_engine/core/models.py +362 -0
- refresh_engine/detection/__init__.py +21 -0
- refresh_engine/detection/detector.py +54 -0
- refresh_engine/detection/fingerprints.py +115 -0
- refresh_engine/errors.py +57 -0
- refresh_engine/events/__init__.py +6 -0
- refresh_engine/events/models.py +42 -0
- refresh_engine/events/publisher.py +77 -0
- refresh_engine/execution/__init__.py +5 -0
- refresh_engine/execution/coordinator.py +128 -0
- refresh_engine/execution/executor.py +180 -0
- refresh_engine/observability/__init__.py +9 -0
- refresh_engine/observability/logging.py +49 -0
- refresh_engine/observability/metrics.py +44 -0
- refresh_engine/planning/__init__.py +6 -0
- refresh_engine/planning/dependency.py +79 -0
- refresh_engine/planning/planner.py +86 -0
- refresh_engine/py.typed +0 -0
- refresh_engine/scheduling/__init__.py +13 -0
- refresh_engine/scheduling/async_scheduler.py +126 -0
- refresh_engine/scheduling/external.py +17 -0
- refresh_engine/stores/__init__.py +11 -0
- refresh_engine/stores/memory.py +77 -0
- refresh_engine/triggers/__init__.py +16 -0
- refresh_engine/triggers/adapters.py +38 -0
- refresh_engine/triggers/selectors.py +46 -0
- refresh_engine-0.1.0.dist-info/METADATA +95 -0
- refresh_engine-0.1.0.dist-info/RECORD +35 -0
- refresh_engine-0.1.0.dist-info/WHEEL +4 -0
- refresh_engine-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Immutable public models used by refresh-engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum, auto
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
ResourceId = str
|
|
14
|
+
Metadata = Mapping[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def utc_now() -> datetime:
|
|
18
|
+
"""Return a timezone-aware UTC timestamp."""
|
|
19
|
+
return datetime.now(UTC)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def freeze_mapping(value: Mapping[str, Any] | None = None) -> Metadata:
|
|
23
|
+
"""Return a shallow immutable copy of metadata."""
|
|
24
|
+
return MappingProxyType(dict(value or {}))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Resource:
|
|
29
|
+
"""Stable resource identity and lightweight discovery metadata."""
|
|
30
|
+
|
|
31
|
+
resource_id: ResourceId
|
|
32
|
+
metadata: Metadata = field(default_factory=freeze_mapping)
|
|
33
|
+
version: str | None = None
|
|
34
|
+
dependencies: frozenset[ResourceId] = frozenset()
|
|
35
|
+
source: str | None = None
|
|
36
|
+
cheap_indicator: str | None = None
|
|
37
|
+
cheap_indicator_reliable: bool = False
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not self.resource_id:
|
|
41
|
+
raise ValueError("resource_id must not be empty")
|
|
42
|
+
object.__setattr__(self, "metadata", freeze_mapping(self.metadata))
|
|
43
|
+
object.__setattr__(self, "dependencies", frozenset(self.dependencies))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ResourceSnapshot:
|
|
48
|
+
"""Observable resource state used to create a strong fingerprint."""
|
|
49
|
+
|
|
50
|
+
resource_id: ResourceId
|
|
51
|
+
content: Any = None
|
|
52
|
+
metadata: Metadata = field(default_factory=freeze_mapping)
|
|
53
|
+
version: str | None = None
|
|
54
|
+
version_token: str | None = None
|
|
55
|
+
etag: str | None = None
|
|
56
|
+
dependencies: frozenset[ResourceId] = frozenset()
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
object.__setattr__(self, "metadata", freeze_mapping(self.metadata))
|
|
60
|
+
object.__setattr__(self, "dependencies", frozenset(self.dependencies))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class DiscoveryResult:
|
|
65
|
+
"""A streaming discovery plus an explicit deletion-safety contract."""
|
|
66
|
+
|
|
67
|
+
resources: AsyncIterator[Resource]
|
|
68
|
+
complete: bool = True
|
|
69
|
+
diagnostics: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ResourceFingerprint:
|
|
74
|
+
"""A namespaced deterministic fingerprint."""
|
|
75
|
+
|
|
76
|
+
algorithm: str
|
|
77
|
+
value: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class ResourceState:
|
|
82
|
+
"""Last successfully processed resource state."""
|
|
83
|
+
|
|
84
|
+
resource_id: ResourceId
|
|
85
|
+
fingerprint: ResourceFingerprint
|
|
86
|
+
version: str | None = None
|
|
87
|
+
cheap_indicator: str | None = None
|
|
88
|
+
snapshot_metadata: Metadata = field(default_factory=freeze_mapping)
|
|
89
|
+
dependencies: frozenset[ResourceId] = frozenset()
|
|
90
|
+
refreshed_at: datetime = field(default_factory=utc_now)
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
object.__setattr__(
|
|
94
|
+
self, "snapshot_metadata", freeze_mapping(self.snapshot_metadata)
|
|
95
|
+
)
|
|
96
|
+
object.__setattr__(self, "dependencies", frozenset(self.dependencies))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ChangeType(StrEnum):
|
|
100
|
+
ADDED = auto()
|
|
101
|
+
MODIFIED = auto()
|
|
102
|
+
DELETED = auto()
|
|
103
|
+
UNCHANGED = auto()
|
|
104
|
+
DEPENDENCY_CHANGED = auto()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class Change:
|
|
109
|
+
resource_id: ResourceId
|
|
110
|
+
change_type: ChangeType
|
|
111
|
+
previous: ResourceState | None = None
|
|
112
|
+
current: ResourceState | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class ChangeSet:
|
|
117
|
+
changes: tuple[Change, ...]
|
|
118
|
+
diagnostics: tuple[str, ...] = ()
|
|
119
|
+
|
|
120
|
+
def of_type(self, change_type: ChangeType) -> tuple[Change, ...]:
|
|
121
|
+
return tuple(c for c in self.changes if c.change_type is change_type)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def added(self) -> tuple[Change, ...]:
|
|
125
|
+
return self.of_type(ChangeType.ADDED)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def modified(self) -> tuple[Change, ...]:
|
|
129
|
+
return self.of_type(ChangeType.MODIFIED)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def deleted(self) -> tuple[Change, ...]:
|
|
133
|
+
return self.of_type(ChangeType.DELETED)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def unchanged(self) -> tuple[Change, ...]:
|
|
137
|
+
return self.of_type(ChangeType.UNCHANGED)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def dependency_changed(self) -> tuple[Change, ...]:
|
|
141
|
+
return self.of_type(ChangeType.DEPENDENCY_CHANGED)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def counts(self) -> Mapping[ChangeType, int]:
|
|
145
|
+
return MappingProxyType({kind: len(self.of_type(kind)) for kind in ChangeType})
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def resource_ids(self) -> frozenset[ResourceId]:
|
|
149
|
+
return frozenset(change.resource_id for change in self.changes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class ImpactSet:
|
|
154
|
+
directly_changed: frozenset[ResourceId]
|
|
155
|
+
indirectly_impacted: frozenset[ResourceId]
|
|
156
|
+
unaffected: frozenset[ResourceId]
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def all_impacted(self) -> frozenset[ResourceId]:
|
|
160
|
+
return self.directly_changed | self.indirectly_impacted
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RefreshMode(StrEnum):
|
|
164
|
+
FULL = auto()
|
|
165
|
+
INCREMENTAL = auto()
|
|
166
|
+
TARGETED = auto()
|
|
167
|
+
DEPENDENCY_AWARE = auto()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TriggerSource(StrEnum):
|
|
171
|
+
MANUAL = auto()
|
|
172
|
+
SCHEDULED = auto()
|
|
173
|
+
EVENT = auto()
|
|
174
|
+
QUERY = auto()
|
|
175
|
+
EXTERNAL = auto()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class OverlapPolicy(StrEnum):
|
|
179
|
+
SKIP_IF_RUNNING = auto()
|
|
180
|
+
QUEUE = auto()
|
|
181
|
+
COALESCE = auto()
|
|
182
|
+
CANCEL_PREVIOUS = auto()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FailurePolicy(StrEnum):
|
|
186
|
+
FAIL_FAST = auto()
|
|
187
|
+
BEST_EFFORT = auto()
|
|
188
|
+
RETRY_THEN_CONTINUE = auto()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class RefreshStatus(StrEnum):
|
|
192
|
+
SUCCESS = auto()
|
|
193
|
+
PARTIAL = auto()
|
|
194
|
+
FAILED = auto()
|
|
195
|
+
CANCELLED = auto()
|
|
196
|
+
SKIPPED = auto()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class RefreshRequest:
|
|
201
|
+
mode: RefreshMode = RefreshMode.INCREMENTAL
|
|
202
|
+
resource_ids: frozenset[ResourceId] = frozenset()
|
|
203
|
+
trigger: TriggerSource = TriggerSource.MANUAL
|
|
204
|
+
priority: int = 0
|
|
205
|
+
metadata: Metadata = field(default_factory=freeze_mapping)
|
|
206
|
+
requested_at: datetime = field(default_factory=utc_now)
|
|
207
|
+
correlation_id: str | None = None
|
|
208
|
+
|
|
209
|
+
def __post_init__(self) -> None:
|
|
210
|
+
object.__setattr__(self, "resource_ids", frozenset(self.resource_ids))
|
|
211
|
+
object.__setattr__(self, "metadata", freeze_mapping(self.metadata))
|
|
212
|
+
if self.mode is RefreshMode.TARGETED and not self.resource_ids:
|
|
213
|
+
raise ValueError("targeted refresh requires resource_ids")
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def deduplication_key(self) -> tuple[RefreshMode, frozenset[str], str | None]:
|
|
217
|
+
return self.mode, self.resource_ids, self.correlation_id
|
|
218
|
+
|
|
219
|
+
def merge(self, other: RefreshRequest) -> RefreshRequest:
|
|
220
|
+
mode = (
|
|
221
|
+
RefreshMode.FULL
|
|
222
|
+
if RefreshMode.FULL in (self.mode, other.mode)
|
|
223
|
+
else (
|
|
224
|
+
RefreshMode.DEPENDENCY_AWARE
|
|
225
|
+
if RefreshMode.DEPENDENCY_AWARE in (self.mode, other.mode)
|
|
226
|
+
else self.mode
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return RefreshRequest(
|
|
230
|
+
mode=mode,
|
|
231
|
+
resource_ids=self.resource_ids | other.resource_ids,
|
|
232
|
+
trigger=other.trigger,
|
|
233
|
+
priority=max(self.priority, other.priority),
|
|
234
|
+
metadata={**self.metadata, **other.metadata},
|
|
235
|
+
requested_at=min(self.requested_at, other.requested_at),
|
|
236
|
+
correlation_id=other.correlation_id or self.correlation_id,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class PlanAction(StrEnum):
|
|
241
|
+
REFRESH = auto()
|
|
242
|
+
DELETE = auto()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True, slots=True)
|
|
246
|
+
class PlanItem:
|
|
247
|
+
resource_id: ResourceId
|
|
248
|
+
action: PlanAction
|
|
249
|
+
state: ResourceState | None
|
|
250
|
+
dependencies: frozenset[ResourceId] = frozenset()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True, slots=True)
|
|
254
|
+
class RefreshPlan:
|
|
255
|
+
refresh_id: str
|
|
256
|
+
request: RefreshRequest
|
|
257
|
+
levels: tuple[tuple[PlanItem, ...], ...]
|
|
258
|
+
skipped_count: int
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def items(self) -> tuple[PlanItem, ...]:
|
|
262
|
+
return tuple(item for level in self.levels for item in level)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True, slots=True)
|
|
266
|
+
class RefreshIssue:
|
|
267
|
+
phase: str
|
|
268
|
+
message: str
|
|
269
|
+
resource_id: ResourceId | None = None
|
|
270
|
+
exception_type: str | None = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True, slots=True)
|
|
274
|
+
class ResourceExecutionResult:
|
|
275
|
+
resource_id: ResourceId
|
|
276
|
+
action: PlanAction
|
|
277
|
+
succeeded: bool
|
|
278
|
+
attempts: int
|
|
279
|
+
duration: float
|
|
280
|
+
error: RefreshIssue | None = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True, slots=True)
|
|
284
|
+
class RefreshResult:
|
|
285
|
+
refresh_id: str
|
|
286
|
+
status: RefreshStatus
|
|
287
|
+
started_at: datetime
|
|
288
|
+
completed_at: datetime
|
|
289
|
+
duration: float
|
|
290
|
+
discovered_count: int = 0
|
|
291
|
+
added_count: int = 0
|
|
292
|
+
modified_count: int = 0
|
|
293
|
+
deleted_count: int = 0
|
|
294
|
+
unchanged_count: int = 0
|
|
295
|
+
impacted_count: int = 0
|
|
296
|
+
refreshed_count: int = 0
|
|
297
|
+
skipped_count: int = 0
|
|
298
|
+
failed_count: int = 0
|
|
299
|
+
errors: tuple[RefreshIssue, ...] = ()
|
|
300
|
+
warnings: tuple[str, ...] = ()
|
|
301
|
+
executions: tuple[ResourceExecutionResult, ...] = ()
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def skipped(cls, reason: str) -> RefreshResult:
|
|
305
|
+
now = utc_now()
|
|
306
|
+
return cls(
|
|
307
|
+
refresh_id=str(uuid4()),
|
|
308
|
+
status=RefreshStatus.SKIPPED,
|
|
309
|
+
started_at=now,
|
|
310
|
+
completed_at=now,
|
|
311
|
+
duration=0.0,
|
|
312
|
+
skipped_count=1,
|
|
313
|
+
warnings=(reason,),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True, slots=True)
|
|
318
|
+
class RetryConfig:
|
|
319
|
+
max_attempts: int = 1
|
|
320
|
+
initial_delay: float = 0.1
|
|
321
|
+
max_delay: float = 5.0
|
|
322
|
+
exponential_base: float = 2.0
|
|
323
|
+
jitter: float = 0.0
|
|
324
|
+
retryable_exceptions: tuple[type[Exception], ...] = (Exception,)
|
|
325
|
+
|
|
326
|
+
def __post_init__(self) -> None:
|
|
327
|
+
if self.max_attempts < 1:
|
|
328
|
+
raise ValueError("max_attempts must be at least 1")
|
|
329
|
+
if min(self.initial_delay, self.max_delay, self.jitter) < 0:
|
|
330
|
+
raise ValueError("retry delays must not be negative")
|
|
331
|
+
if self.exponential_base < 1:
|
|
332
|
+
raise ValueError("exponential_base must be at least 1")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass(frozen=True, slots=True)
|
|
336
|
+
class TimeoutConfig:
|
|
337
|
+
discovery: float | None = None
|
|
338
|
+
snapshot: float | None = None
|
|
339
|
+
resource_refresh: float | None = None
|
|
340
|
+
overall: float | None = None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True, slots=True)
|
|
344
|
+
class RefreshConfig:
|
|
345
|
+
max_concurrency: int = 10
|
|
346
|
+
overlap_policy: OverlapPolicy = OverlapPolicy.COALESCE
|
|
347
|
+
failure_policy: FailurePolicy = FailurePolicy.BEST_EFFORT
|
|
348
|
+
retry: RetryConfig = field(default_factory=RetryConfig)
|
|
349
|
+
timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
|
|
350
|
+
cancel_active_on_shutdown: bool = False
|
|
351
|
+
|
|
352
|
+
def __post_init__(self) -> None:
|
|
353
|
+
if self.max_concurrency < 1:
|
|
354
|
+
raise ValueError("max_concurrency must be at least 1")
|
|
355
|
+
for value in (
|
|
356
|
+
self.timeout.discovery,
|
|
357
|
+
self.timeout.snapshot,
|
|
358
|
+
self.timeout.resource_refresh,
|
|
359
|
+
self.timeout.overall,
|
|
360
|
+
):
|
|
361
|
+
if value is not None and value <= 0:
|
|
362
|
+
raise ValueError("timeouts must be positive")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Change detection and fingerprinting."""
|
|
2
|
+
|
|
3
|
+
from refresh_engine.detection.detector import ChangeDetector
|
|
4
|
+
from refresh_engine.detection.fingerprints import (
|
|
5
|
+
CompositeHash,
|
|
6
|
+
ContentHash,
|
|
7
|
+
ETagFingerprint,
|
|
8
|
+
MetadataHash,
|
|
9
|
+
VersionTokenFingerprint,
|
|
10
|
+
canonical_bytes,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ChangeDetector",
|
|
15
|
+
"CompositeHash",
|
|
16
|
+
"ContentHash",
|
|
17
|
+
"ETagFingerprint",
|
|
18
|
+
"MetadataHash",
|
|
19
|
+
"VersionTokenFingerprint",
|
|
20
|
+
"canonical_bytes",
|
|
21
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Change classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from refresh_engine.core.models import (
|
|
8
|
+
Change,
|
|
9
|
+
ChangeSet,
|
|
10
|
+
ChangeType,
|
|
11
|
+
ResourceState,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChangeDetector:
|
|
16
|
+
"""Compare current candidates with the last successful state."""
|
|
17
|
+
|
|
18
|
+
def detect(
|
|
19
|
+
self,
|
|
20
|
+
previous: Mapping[str, ResourceState],
|
|
21
|
+
current: Mapping[str, ResourceState],
|
|
22
|
+
*,
|
|
23
|
+
discovery_complete: bool,
|
|
24
|
+
diagnostics: tuple[str, ...] = (),
|
|
25
|
+
) -> ChangeSet:
|
|
26
|
+
changes: list[Change] = []
|
|
27
|
+
for resource_id, state in current.items():
|
|
28
|
+
old = previous.get(resource_id)
|
|
29
|
+
if old is None:
|
|
30
|
+
kind = ChangeType.ADDED
|
|
31
|
+
elif old.fingerprint != state.fingerprint:
|
|
32
|
+
kind = ChangeType.MODIFIED
|
|
33
|
+
elif old.dependencies != state.dependencies:
|
|
34
|
+
kind = ChangeType.DEPENDENCY_CHANGED
|
|
35
|
+
else:
|
|
36
|
+
kind = ChangeType.UNCHANGED
|
|
37
|
+
changes.append(Change(resource_id, kind, old, state))
|
|
38
|
+
|
|
39
|
+
if discovery_complete:
|
|
40
|
+
for resource_id in previous.keys() - current.keys():
|
|
41
|
+
changes.append(
|
|
42
|
+
Change(
|
|
43
|
+
resource_id,
|
|
44
|
+
ChangeType.DELETED,
|
|
45
|
+
previous[resource_id],
|
|
46
|
+
None,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
elif previous.keys() - current.keys():
|
|
50
|
+
diagnostics += (
|
|
51
|
+
"deletion inference suppressed because discovery was incomplete",
|
|
52
|
+
)
|
|
53
|
+
changes.sort(key=lambda change: change.resource_id)
|
|
54
|
+
return ChangeSet(tuple(changes), diagnostics)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Deterministic fingerprint strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from collections.abc import Mapping, Sequence, Set
|
|
10
|
+
from dataclasses import asdict, is_dataclass
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import PurePath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from refresh_engine.api.abc import FingerprintStrategyABC
|
|
17
|
+
from refresh_engine.core.models import ResourceFingerprint, ResourceSnapshot
|
|
18
|
+
from refresh_engine.errors import FingerprintError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _canonical(value: Any) -> Any:
|
|
22
|
+
if value is None:
|
|
23
|
+
return ["null", None]
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
return ["bool", value]
|
|
26
|
+
if isinstance(value, int):
|
|
27
|
+
return ["int", str(value)]
|
|
28
|
+
if isinstance(value, float):
|
|
29
|
+
if not math.isfinite(value):
|
|
30
|
+
raise FingerprintError("non-finite floats cannot be fingerprinted")
|
|
31
|
+
return ["float", value.hex()]
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return ["str", value]
|
|
34
|
+
if isinstance(value, bytes):
|
|
35
|
+
return ["bytes", base64.b64encode(value).decode("ascii")]
|
|
36
|
+
if isinstance(value, (datetime, date)):
|
|
37
|
+
return [type(value).__name__, value.isoformat()]
|
|
38
|
+
if isinstance(value, PurePath):
|
|
39
|
+
return ["path", value.as_posix()]
|
|
40
|
+
if isinstance(value, Enum):
|
|
41
|
+
return [
|
|
42
|
+
"enum",
|
|
43
|
+
f"{type(value).__module__}.{type(value).__qualname__}",
|
|
44
|
+
value.value,
|
|
45
|
+
]
|
|
46
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
47
|
+
return ["dataclass", _canonical(asdict(value))]
|
|
48
|
+
if isinstance(value, Mapping):
|
|
49
|
+
pairs = [(_canonical(key), _canonical(item)) for key, item in value.items()]
|
|
50
|
+
pairs.sort(key=lambda pair: _encode(pair[0]))
|
|
51
|
+
return ["mapping", pairs]
|
|
52
|
+
if isinstance(value, Set):
|
|
53
|
+
items = [_canonical(item) for item in value]
|
|
54
|
+
items.sort(key=_encode)
|
|
55
|
+
return ["set", items]
|
|
56
|
+
if isinstance(value, Sequence):
|
|
57
|
+
return ["sequence", [_canonical(item) for item in value]]
|
|
58
|
+
raise FingerprintError(f"unsupported fingerprint value: {type(value).__qualname__}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _encode(value: Any) -> bytes:
|
|
62
|
+
return json.dumps(
|
|
63
|
+
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
64
|
+
).encode("utf-8")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
68
|
+
"""Encode a supported value deterministically and with type information."""
|
|
69
|
+
return _encode(_canonical(value))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sha256(namespace: str, value: Any) -> ResourceFingerprint:
|
|
73
|
+
digest = hashlib.sha256(canonical_bytes(value)).hexdigest()
|
|
74
|
+
return ResourceFingerprint(f"sha256:{namespace}", digest)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ContentHash(FingerprintStrategyABC):
|
|
78
|
+
async def fingerprint(self, snapshot: ResourceSnapshot) -> ResourceFingerprint:
|
|
79
|
+
return _sha256("content", snapshot.content)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MetadataHash(FingerprintStrategyABC):
|
|
83
|
+
async def fingerprint(self, snapshot: ResourceSnapshot) -> ResourceFingerprint:
|
|
84
|
+
return _sha256("metadata", snapshot.metadata)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CompositeHash(FingerprintStrategyABC):
|
|
88
|
+
def __init__(self, *, include_dependencies: bool = True) -> None:
|
|
89
|
+
self._include_dependencies = include_dependencies
|
|
90
|
+
|
|
91
|
+
async def fingerprint(self, snapshot: ResourceSnapshot) -> ResourceFingerprint:
|
|
92
|
+
value = {
|
|
93
|
+
"content": snapshot.content,
|
|
94
|
+
"metadata": snapshot.metadata,
|
|
95
|
+
"version": snapshot.version,
|
|
96
|
+
"version_token": snapshot.version_token,
|
|
97
|
+
"etag": snapshot.etag,
|
|
98
|
+
}
|
|
99
|
+
if self._include_dependencies:
|
|
100
|
+
value["dependencies"] = sorted(snapshot.dependencies)
|
|
101
|
+
return _sha256("composite", value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class VersionTokenFingerprint(FingerprintStrategyABC):
|
|
105
|
+
async def fingerprint(self, snapshot: ResourceSnapshot) -> ResourceFingerprint:
|
|
106
|
+
if snapshot.version_token is None:
|
|
107
|
+
raise FingerprintError("snapshot has no version_token")
|
|
108
|
+
return _sha256("version-token", snapshot.version_token)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ETagFingerprint(FingerprintStrategyABC):
|
|
112
|
+
async def fingerprint(self, snapshot: ResourceSnapshot) -> ResourceFingerprint:
|
|
113
|
+
if snapshot.etag is None:
|
|
114
|
+
raise FingerprintError("snapshot has no etag")
|
|
115
|
+
return _sha256("etag", snapshot.etag)
|
refresh_engine/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Structured refresh-engine exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RefreshError(Exception):
|
|
5
|
+
"""Base exception for refresh-engine failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(RefreshError):
|
|
9
|
+
"""Invalid configuration."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DiscoveryError(RefreshError):
|
|
13
|
+
"""Resource discovery failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SnapshotError(RefreshError):
|
|
17
|
+
"""Resource snapshotting failed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FingerprintError(RefreshError):
|
|
21
|
+
"""Fingerprint creation failed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChangeDetectionError(RefreshError):
|
|
25
|
+
"""Change detection failed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DependencyError(RefreshError):
|
|
29
|
+
"""Dependency graph operation failed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DependencyCycleError(DependencyError):
|
|
33
|
+
"""A dependency cycle prevents deterministic execution."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, cycle: tuple[str, ...]) -> None:
|
|
36
|
+
self.cycle = cycle
|
|
37
|
+
super().__init__(f"dependency cycle detected: {' -> '.join(cycle)}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PlanningError(RefreshError):
|
|
41
|
+
"""Refresh planning failed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExecutionError(RefreshError):
|
|
45
|
+
"""Refresh execution failed."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StateStoreError(RefreshError):
|
|
49
|
+
"""State persistence failed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SchedulerError(RefreshError):
|
|
53
|
+
"""Scheduler lifecycle operation failed."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CancellationError(RefreshError):
|
|
57
|
+
"""A refresh was cancelled by policy."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Structured lifecycle events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum, auto
|
|
8
|
+
|
|
9
|
+
from refresh_engine.core.models import Metadata, freeze_mapping, utc_now
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventKind(StrEnum):
|
|
13
|
+
REFRESH_STARTED = auto()
|
|
14
|
+
DISCOVERY_STARTED = auto()
|
|
15
|
+
DISCOVERY_COMPLETED = auto()
|
|
16
|
+
DETECTION_STARTED = auto()
|
|
17
|
+
DETECTION_COMPLETED = auto()
|
|
18
|
+
PLANNING_STARTED = auto()
|
|
19
|
+
PLANNING_COMPLETED = auto()
|
|
20
|
+
EXECUTION_STARTED = auto()
|
|
21
|
+
RESOURCE_REFRESH_STARTED = auto()
|
|
22
|
+
RESOURCE_REFRESH_COMPLETED = auto()
|
|
23
|
+
RESOURCE_REFRESH_FAILED = auto()
|
|
24
|
+
EXECUTION_COMPLETED = auto()
|
|
25
|
+
STATE_PERSISTED = auto()
|
|
26
|
+
REFRESH_COMPLETED = auto()
|
|
27
|
+
REFRESH_FAILED = auto()
|
|
28
|
+
REFRESH_CANCELLED = auto()
|
|
29
|
+
SCHEDULER_STARTED = auto()
|
|
30
|
+
SCHEDULER_STOPPED = auto()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class RefreshEvent:
|
|
35
|
+
kind: EventKind
|
|
36
|
+
refresh_id: str | None = None
|
|
37
|
+
resource_id: str | None = None
|
|
38
|
+
timestamp: datetime = field(default_factory=utc_now)
|
|
39
|
+
attributes: Metadata = field(default_factory=freeze_mapping)
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
object.__setattr__(self, "attributes", freeze_mapping(self.attributes))
|