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,468 @@
|
|
|
1
|
+
"""High-level refresh orchestration API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from refresh_engine.api.protocols import (
|
|
11
|
+
FingerprintStrategy,
|
|
12
|
+
RefreshOperation,
|
|
13
|
+
ResourceSource,
|
|
14
|
+
StateStore,
|
|
15
|
+
)
|
|
16
|
+
from refresh_engine.core.models import (
|
|
17
|
+
ChangeSet,
|
|
18
|
+
FailurePolicy,
|
|
19
|
+
RefreshConfig,
|
|
20
|
+
RefreshIssue,
|
|
21
|
+
RefreshMode,
|
|
22
|
+
RefreshRequest,
|
|
23
|
+
RefreshResult,
|
|
24
|
+
RefreshStatus,
|
|
25
|
+
Resource,
|
|
26
|
+
ResourceSnapshot,
|
|
27
|
+
ResourceState,
|
|
28
|
+
TriggerSource,
|
|
29
|
+
utc_now,
|
|
30
|
+
)
|
|
31
|
+
from refresh_engine.detection import ChangeDetector, CompositeHash
|
|
32
|
+
from refresh_engine.errors import DiscoveryError
|
|
33
|
+
from refresh_engine.events import EventKind, EventPublisher, RefreshEvent
|
|
34
|
+
from refresh_engine.execution.coordinator import RefreshCoordinator
|
|
35
|
+
from refresh_engine.execution.executor import RefreshExecutor
|
|
36
|
+
from refresh_engine.planning import DependencyGraph, ImpactAnalyzer, RefreshPlanner
|
|
37
|
+
from refresh_engine.stores import InMemoryStateStore
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RefreshEngine:
|
|
41
|
+
"""Generic change detection and refresh orchestration."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
source: ResourceSource,
|
|
46
|
+
operation: RefreshOperation,
|
|
47
|
+
*,
|
|
48
|
+
fingerprint: FingerprintStrategy | None = None,
|
|
49
|
+
store: StateStore | None = None,
|
|
50
|
+
config: RefreshConfig | None = None,
|
|
51
|
+
events: EventPublisher | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.source = source
|
|
54
|
+
self.operation = operation
|
|
55
|
+
self.fingerprint = fingerprint or CompositeHash()
|
|
56
|
+
self.store = store or InMemoryStateStore()
|
|
57
|
+
self.config = config or RefreshConfig()
|
|
58
|
+
self.events = events or EventPublisher()
|
|
59
|
+
self._detector = ChangeDetector()
|
|
60
|
+
self._planner = RefreshPlanner()
|
|
61
|
+
self._impact = ImpactAnalyzer()
|
|
62
|
+
self._executor = RefreshExecutor(operation, self.config, self.events)
|
|
63
|
+
self._coordinator = RefreshCoordinator(
|
|
64
|
+
self._run_refresh, self.config.overlap_policy
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def refresh(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
mode: RefreshMode = RefreshMode.INCREMENTAL,
|
|
71
|
+
resource_ids: frozenset[str] | set[str] | tuple[str, ...] = frozenset(),
|
|
72
|
+
trigger: TriggerSource = TriggerSource.MANUAL,
|
|
73
|
+
metadata: Mapping[str, object] | None = None,
|
|
74
|
+
correlation_id: str | None = None,
|
|
75
|
+
) -> RefreshResult:
|
|
76
|
+
request = RefreshRequest(
|
|
77
|
+
mode=mode,
|
|
78
|
+
resource_ids=frozenset(resource_ids),
|
|
79
|
+
trigger=trigger,
|
|
80
|
+
metadata=metadata or {},
|
|
81
|
+
correlation_id=correlation_id,
|
|
82
|
+
)
|
|
83
|
+
return await self.submit(request)
|
|
84
|
+
|
|
85
|
+
async def submit(self, request: RefreshRequest) -> RefreshResult:
|
|
86
|
+
return await self._coordinator.submit(request)
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
await self._coordinator.close(
|
|
90
|
+
cancel_active=self.config.cancel_active_on_shutdown
|
|
91
|
+
)
|
|
92
|
+
await self.events.drain()
|
|
93
|
+
|
|
94
|
+
async def _run_refresh(self, request: RefreshRequest) -> RefreshResult:
|
|
95
|
+
if self.config.timeout.overall is None:
|
|
96
|
+
return await self._execute_lifecycle(request)
|
|
97
|
+
return await asyncio.wait_for(
|
|
98
|
+
self._execute_lifecycle(request), self.config.timeout.overall
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def _execute_lifecycle(self, request: RefreshRequest) -> RefreshResult:
|
|
102
|
+
refresh_id = str(uuid4())
|
|
103
|
+
started_at = utc_now()
|
|
104
|
+
timer = perf_counter()
|
|
105
|
+
await self.events.publish(RefreshEvent(EventKind.REFRESH_STARTED, refresh_id))
|
|
106
|
+
try:
|
|
107
|
+
previous = await self.store.load_all()
|
|
108
|
+
resources, complete, discovery_diagnostics = await self._discover(
|
|
109
|
+
refresh_id
|
|
110
|
+
)
|
|
111
|
+
states, snapshots, preprocessing_errors = (
|
|
112
|
+
await self._snapshot_and_fingerprint(
|
|
113
|
+
refresh_id, request, resources, previous
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
await self.events.publish(
|
|
117
|
+
RefreshEvent(EventKind.DETECTION_STARTED, refresh_id)
|
|
118
|
+
)
|
|
119
|
+
changes = self._detector.detect(
|
|
120
|
+
previous,
|
|
121
|
+
states,
|
|
122
|
+
discovery_complete=complete,
|
|
123
|
+
diagnostics=discovery_diagnostics,
|
|
124
|
+
)
|
|
125
|
+
await self.events.publish(
|
|
126
|
+
RefreshEvent(EventKind.DETECTION_COMPLETED, refresh_id)
|
|
127
|
+
)
|
|
128
|
+
graph = self._build_graph(previous, states)
|
|
129
|
+
impact = self._impact.analyze(changes, graph)
|
|
130
|
+
await self.events.publish(
|
|
131
|
+
RefreshEvent(EventKind.PLANNING_STARTED, refresh_id)
|
|
132
|
+
)
|
|
133
|
+
plan = self._planner.plan(
|
|
134
|
+
refresh_id, request, changes, impact, graph, states
|
|
135
|
+
)
|
|
136
|
+
await self._load_plan_snapshots(plan, resources, snapshots)
|
|
137
|
+
await self.events.publish(
|
|
138
|
+
RefreshEvent(EventKind.PLANNING_COMPLETED, refresh_id)
|
|
139
|
+
)
|
|
140
|
+
executions = await self._executor.execute(plan, resources, snapshots)
|
|
141
|
+
await self._persist(refresh_id, states, changes, executions)
|
|
142
|
+
errors = preprocessing_errors + tuple(
|
|
143
|
+
result.error for result in executions if result.error is not None
|
|
144
|
+
)
|
|
145
|
+
failed_count = len(errors)
|
|
146
|
+
status = (
|
|
147
|
+
RefreshStatus.SUCCESS
|
|
148
|
+
if failed_count == 0
|
|
149
|
+
else (
|
|
150
|
+
RefreshStatus.FAILED
|
|
151
|
+
if not executions
|
|
152
|
+
or self.config.failure_policy is FailurePolicy.FAIL_FAST
|
|
153
|
+
else RefreshStatus.PARTIAL
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
result = self._result(
|
|
157
|
+
refresh_id,
|
|
158
|
+
status,
|
|
159
|
+
started_at,
|
|
160
|
+
timer,
|
|
161
|
+
len(resources),
|
|
162
|
+
changes,
|
|
163
|
+
len(impact.indirectly_impacted),
|
|
164
|
+
executions,
|
|
165
|
+
plan.skipped_count,
|
|
166
|
+
errors,
|
|
167
|
+
changes.diagnostics,
|
|
168
|
+
)
|
|
169
|
+
await self.events.publish(
|
|
170
|
+
RefreshEvent(EventKind.REFRESH_COMPLETED, refresh_id)
|
|
171
|
+
)
|
|
172
|
+
return result
|
|
173
|
+
except asyncio.CancelledError:
|
|
174
|
+
await self.events.publish(
|
|
175
|
+
RefreshEvent(EventKind.REFRESH_CANCELLED, refresh_id)
|
|
176
|
+
)
|
|
177
|
+
raise
|
|
178
|
+
except Exception as error:
|
|
179
|
+
issue = RefreshIssue(
|
|
180
|
+
"refresh", str(error), exception_type=type(error).__name__
|
|
181
|
+
)
|
|
182
|
+
await self.events.publish(
|
|
183
|
+
RefreshEvent(
|
|
184
|
+
EventKind.REFRESH_FAILED,
|
|
185
|
+
refresh_id,
|
|
186
|
+
attributes={"error_type": type(error).__name__},
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return self._result(
|
|
190
|
+
refresh_id,
|
|
191
|
+
RefreshStatus.FAILED,
|
|
192
|
+
started_at,
|
|
193
|
+
timer,
|
|
194
|
+
0,
|
|
195
|
+
ChangeSet(()),
|
|
196
|
+
0,
|
|
197
|
+
(),
|
|
198
|
+
0,
|
|
199
|
+
(issue,),
|
|
200
|
+
(),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
async def _discover(
|
|
204
|
+
self, refresh_id: str
|
|
205
|
+
) -> tuple[dict[str, Resource], bool, tuple[str, ...]]:
|
|
206
|
+
await self.events.publish(RefreshEvent(EventKind.DISCOVERY_STARTED, refresh_id))
|
|
207
|
+
|
|
208
|
+
async def consume() -> tuple[dict[str, Resource], bool, tuple[str, ...]]:
|
|
209
|
+
try:
|
|
210
|
+
result = await self.source.discover()
|
|
211
|
+
resources: dict[str, Resource] = {}
|
|
212
|
+
async for resource in result.resources:
|
|
213
|
+
if resource.resource_id in resources:
|
|
214
|
+
raise DiscoveryError(
|
|
215
|
+
f"duplicate resource id: {resource.resource_id}"
|
|
216
|
+
)
|
|
217
|
+
resources[resource.resource_id] = resource
|
|
218
|
+
return resources, result.complete, result.diagnostics
|
|
219
|
+
except asyncio.CancelledError:
|
|
220
|
+
raise
|
|
221
|
+
except DiscoveryError:
|
|
222
|
+
raise
|
|
223
|
+
except Exception as error:
|
|
224
|
+
raise DiscoveryError("resource discovery failed") from error
|
|
225
|
+
|
|
226
|
+
if self.config.timeout.discovery is None:
|
|
227
|
+
discovered = await consume()
|
|
228
|
+
else:
|
|
229
|
+
discovered = await asyncio.wait_for(
|
|
230
|
+
consume(), self.config.timeout.discovery
|
|
231
|
+
)
|
|
232
|
+
await self.events.publish(
|
|
233
|
+
RefreshEvent(
|
|
234
|
+
EventKind.DISCOVERY_COMPLETED,
|
|
235
|
+
refresh_id,
|
|
236
|
+
attributes={"count": len(discovered[0]), "complete": discovered[1]},
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
return discovered
|
|
240
|
+
|
|
241
|
+
async def _snapshot_and_fingerprint(
|
|
242
|
+
self,
|
|
243
|
+
refresh_id: str,
|
|
244
|
+
request: RefreshRequest,
|
|
245
|
+
resources: Mapping[str, Resource],
|
|
246
|
+
previous: Mapping[str, ResourceState],
|
|
247
|
+
) -> tuple[
|
|
248
|
+
dict[str, ResourceState],
|
|
249
|
+
dict[str, ResourceSnapshot],
|
|
250
|
+
tuple[RefreshIssue, ...],
|
|
251
|
+
]:
|
|
252
|
+
states: dict[str, ResourceState] = {}
|
|
253
|
+
snapshots: dict[str, ResourceSnapshot] = {}
|
|
254
|
+
errors: list[RefreshIssue] = []
|
|
255
|
+
queue: asyncio.Queue[Resource | None] = asyncio.Queue()
|
|
256
|
+
for resource in resources.values():
|
|
257
|
+
queue.put_nowait(resource)
|
|
258
|
+
worker_count = min(self.config.max_concurrency, max(1, len(resources)))
|
|
259
|
+
for _ in range(worker_count):
|
|
260
|
+
queue.put_nowait(None)
|
|
261
|
+
|
|
262
|
+
async def worker() -> None:
|
|
263
|
+
while (resource := await queue.get()) is not None:
|
|
264
|
+
old = previous.get(resource.resource_id)
|
|
265
|
+
force_snapshot = request.mode is RefreshMode.FULL or (
|
|
266
|
+
request.mode is RefreshMode.TARGETED
|
|
267
|
+
and resource.resource_id in request.resource_ids
|
|
268
|
+
)
|
|
269
|
+
cheap_match = (
|
|
270
|
+
not force_snapshot
|
|
271
|
+
and old is not None
|
|
272
|
+
and resource.cheap_indicator_reliable
|
|
273
|
+
and resource.cheap_indicator is not None
|
|
274
|
+
and resource.cheap_indicator == old.cheap_indicator
|
|
275
|
+
)
|
|
276
|
+
if cheap_match:
|
|
277
|
+
states[resource.resource_id] = ResourceState(
|
|
278
|
+
resource.resource_id,
|
|
279
|
+
old.fingerprint,
|
|
280
|
+
resource.version,
|
|
281
|
+
resource.cheap_indicator,
|
|
282
|
+
old.snapshot_metadata,
|
|
283
|
+
resource.dependencies,
|
|
284
|
+
old.refreshed_at,
|
|
285
|
+
)
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
snapshot_call = self.source.snapshot(resource)
|
|
289
|
+
if self.config.timeout.snapshot is None:
|
|
290
|
+
snapshot = await snapshot_call
|
|
291
|
+
else:
|
|
292
|
+
snapshot = await asyncio.wait_for(
|
|
293
|
+
snapshot_call, self.config.timeout.snapshot
|
|
294
|
+
)
|
|
295
|
+
fingerprint = await self.fingerprint.fingerprint(snapshot)
|
|
296
|
+
snapshots[resource.resource_id] = snapshot
|
|
297
|
+
states[resource.resource_id] = ResourceState(
|
|
298
|
+
resource.resource_id,
|
|
299
|
+
fingerprint,
|
|
300
|
+
snapshot.version or resource.version,
|
|
301
|
+
resource.cheap_indicator,
|
|
302
|
+
snapshot.metadata,
|
|
303
|
+
snapshot.dependencies or resource.dependencies,
|
|
304
|
+
)
|
|
305
|
+
except asyncio.CancelledError:
|
|
306
|
+
raise
|
|
307
|
+
except Exception as error:
|
|
308
|
+
errors.append(
|
|
309
|
+
RefreshIssue(
|
|
310
|
+
"snapshot",
|
|
311
|
+
str(error),
|
|
312
|
+
resource.resource_id,
|
|
313
|
+
type(error).__name__,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
if old is not None:
|
|
317
|
+
states[resource.resource_id] = old
|
|
318
|
+
|
|
319
|
+
workers = [
|
|
320
|
+
asyncio.create_task(worker(), name=f"refresh:{refresh_id}:snapshot:{index}")
|
|
321
|
+
for index in range(worker_count)
|
|
322
|
+
]
|
|
323
|
+
try:
|
|
324
|
+
await asyncio.gather(*workers)
|
|
325
|
+
except asyncio.CancelledError:
|
|
326
|
+
for task in workers:
|
|
327
|
+
task.cancel()
|
|
328
|
+
await asyncio.gather(*workers, return_exceptions=True)
|
|
329
|
+
raise
|
|
330
|
+
return states, snapshots, tuple(errors)
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _build_graph(
|
|
334
|
+
previous: Mapping[str, ResourceState],
|
|
335
|
+
current: Mapping[str, ResourceState],
|
|
336
|
+
) -> DependencyGraph:
|
|
337
|
+
graph = DependencyGraph()
|
|
338
|
+
effective = dict(previous)
|
|
339
|
+
effective.update(current)
|
|
340
|
+
for state in effective.values():
|
|
341
|
+
graph.add_resource(state.resource_id)
|
|
342
|
+
for dependency in state.dependencies:
|
|
343
|
+
graph.add_dependency(state.resource_id, dependency)
|
|
344
|
+
return graph
|
|
345
|
+
|
|
346
|
+
async def _load_plan_snapshots(
|
|
347
|
+
self,
|
|
348
|
+
plan: object,
|
|
349
|
+
resources: Mapping[str, Resource],
|
|
350
|
+
snapshots: dict[str, ResourceSnapshot],
|
|
351
|
+
) -> None:
|
|
352
|
+
from refresh_engine.core.models import PlanAction, RefreshPlan
|
|
353
|
+
|
|
354
|
+
assert isinstance(plan, RefreshPlan)
|
|
355
|
+
missing = [
|
|
356
|
+
resources[item.resource_id]
|
|
357
|
+
for item in plan.items
|
|
358
|
+
if item.action is PlanAction.REFRESH
|
|
359
|
+
and item.resource_id in resources
|
|
360
|
+
and item.resource_id not in snapshots
|
|
361
|
+
]
|
|
362
|
+
queue: asyncio.Queue[Resource | None] = asyncio.Queue()
|
|
363
|
+
for resource in missing:
|
|
364
|
+
queue.put_nowait(resource)
|
|
365
|
+
worker_count = min(self.config.max_concurrency, len(missing))
|
|
366
|
+
for _ in range(worker_count):
|
|
367
|
+
queue.put_nowait(None)
|
|
368
|
+
|
|
369
|
+
async def worker() -> None:
|
|
370
|
+
while (resource := await queue.get()) is not None:
|
|
371
|
+
snapshot_call = self.source.snapshot(resource)
|
|
372
|
+
if self.config.timeout.snapshot is None:
|
|
373
|
+
snapshots[resource.resource_id] = await snapshot_call
|
|
374
|
+
else:
|
|
375
|
+
snapshots[resource.resource_id] = await asyncio.wait_for(
|
|
376
|
+
snapshot_call, self.config.timeout.snapshot
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
workers = [
|
|
380
|
+
asyncio.create_task(
|
|
381
|
+
worker(), name=f"refresh:{plan.refresh_id}:plan-snapshot:{index}"
|
|
382
|
+
)
|
|
383
|
+
for index in range(worker_count)
|
|
384
|
+
]
|
|
385
|
+
if workers:
|
|
386
|
+
try:
|
|
387
|
+
await asyncio.gather(*workers)
|
|
388
|
+
except asyncio.CancelledError:
|
|
389
|
+
for task in workers:
|
|
390
|
+
task.cancel()
|
|
391
|
+
await asyncio.gather(*workers, return_exceptions=True)
|
|
392
|
+
raise
|
|
393
|
+
|
|
394
|
+
async def _persist(
|
|
395
|
+
self,
|
|
396
|
+
refresh_id: str,
|
|
397
|
+
states: Mapping[str, ResourceState],
|
|
398
|
+
changes: ChangeSet,
|
|
399
|
+
executions: tuple[object, ...],
|
|
400
|
+
) -> None:
|
|
401
|
+
from refresh_engine.core.models import ResourceExecutionResult
|
|
402
|
+
|
|
403
|
+
typed = tuple(
|
|
404
|
+
result
|
|
405
|
+
for result in executions
|
|
406
|
+
if isinstance(result, ResourceExecutionResult)
|
|
407
|
+
)
|
|
408
|
+
if self.config.failure_policy is FailurePolicy.FAIL_FAST and any(
|
|
409
|
+
not result.succeeded for result in typed
|
|
410
|
+
):
|
|
411
|
+
return
|
|
412
|
+
async with self.store.transaction() as transaction:
|
|
413
|
+
for change in changes.unchanged:
|
|
414
|
+
if change.current is not None:
|
|
415
|
+
await transaction.put(change.current)
|
|
416
|
+
for result in typed:
|
|
417
|
+
if not result.succeeded:
|
|
418
|
+
continue
|
|
419
|
+
if result.action.value == "delete":
|
|
420
|
+
await transaction.delete(result.resource_id)
|
|
421
|
+
elif result.resource_id in states:
|
|
422
|
+
await transaction.put(states[result.resource_id])
|
|
423
|
+
await self.events.publish(RefreshEvent(EventKind.STATE_PERSISTED, refresh_id))
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _result(
|
|
427
|
+
refresh_id: str,
|
|
428
|
+
status: RefreshStatus,
|
|
429
|
+
started_at: object,
|
|
430
|
+
timer: float,
|
|
431
|
+
discovered_count: int,
|
|
432
|
+
changes: ChangeSet,
|
|
433
|
+
impacted_count: int,
|
|
434
|
+
executions: tuple[object, ...],
|
|
435
|
+
skipped_count: int,
|
|
436
|
+
errors: tuple[RefreshIssue, ...],
|
|
437
|
+
warnings: tuple[str, ...],
|
|
438
|
+
) -> RefreshResult:
|
|
439
|
+
from datetime import datetime
|
|
440
|
+
|
|
441
|
+
from refresh_engine.core.models import ResourceExecutionResult
|
|
442
|
+
|
|
443
|
+
assert isinstance(started_at, datetime)
|
|
444
|
+
typed = tuple(
|
|
445
|
+
result
|
|
446
|
+
for result in executions
|
|
447
|
+
if isinstance(result, ResourceExecutionResult)
|
|
448
|
+
)
|
|
449
|
+
completed = utc_now()
|
|
450
|
+
return RefreshResult(
|
|
451
|
+
refresh_id=refresh_id,
|
|
452
|
+
status=status,
|
|
453
|
+
started_at=started_at,
|
|
454
|
+
completed_at=completed,
|
|
455
|
+
duration=perf_counter() - timer,
|
|
456
|
+
discovered_count=discovered_count,
|
|
457
|
+
added_count=len(changes.added),
|
|
458
|
+
modified_count=len(changes.modified),
|
|
459
|
+
deleted_count=len(changes.deleted),
|
|
460
|
+
unchanged_count=len(changes.unchanged),
|
|
461
|
+
impacted_count=impacted_count,
|
|
462
|
+
refreshed_count=sum(result.succeeded for result in typed),
|
|
463
|
+
skipped_count=skipped_count,
|
|
464
|
+
failed_count=len(errors),
|
|
465
|
+
errors=errors,
|
|
466
|
+
warnings=warnings,
|
|
467
|
+
executions=typed,
|
|
468
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Extension protocols for sources, operations, stores, and observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from refresh_engine.core.models import (
|
|
9
|
+
DiscoveryResult,
|
|
10
|
+
PlanAction,
|
|
11
|
+
RefreshRequest,
|
|
12
|
+
Resource,
|
|
13
|
+
ResourceFingerprint,
|
|
14
|
+
ResourceSnapshot,
|
|
15
|
+
ResourceState,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class ResourceSource(Protocol):
|
|
21
|
+
async def discover(self) -> DiscoveryResult:
|
|
22
|
+
"""Start a streaming discovery."""
|
|
23
|
+
|
|
24
|
+
async def snapshot(self, resource: Resource) -> ResourceSnapshot:
|
|
25
|
+
"""Return observable state for one resource."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class FingerprintStrategy(Protocol):
|
|
30
|
+
async def fingerprint(self, snapshot: ResourceSnapshot) -> ResourceFingerprint:
|
|
31
|
+
"""Return a deterministic strong fingerprint."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class RefreshOperation(Protocol):
|
|
36
|
+
async def __call__(
|
|
37
|
+
self,
|
|
38
|
+
resource: Resource | None,
|
|
39
|
+
snapshot: ResourceSnapshot | None,
|
|
40
|
+
action: PlanAction,
|
|
41
|
+
request: RefreshRequest,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Perform the application-defined refresh or deletion side effect."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class StateTransaction(Protocol):
|
|
48
|
+
async def put(self, state: ResourceState) -> None: ...
|
|
49
|
+
|
|
50
|
+
async def delete(self, resource_id: str) -> None: ...
|
|
51
|
+
|
|
52
|
+
async def commit(self) -> None: ...
|
|
53
|
+
|
|
54
|
+
async def rollback(self) -> None: ...
|
|
55
|
+
|
|
56
|
+
async def __aenter__(self) -> StateTransaction: ...
|
|
57
|
+
|
|
58
|
+
async def __aexit__(
|
|
59
|
+
self,
|
|
60
|
+
exc_type: type[BaseException] | None,
|
|
61
|
+
exc: BaseException | None,
|
|
62
|
+
traceback: object | None,
|
|
63
|
+
) -> None: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class StateStore(Protocol):
|
|
68
|
+
async def load_all(self) -> Mapping[str, ResourceState]: ...
|
|
69
|
+
|
|
70
|
+
def transaction(self) -> StateTransaction: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@runtime_checkable
|
|
74
|
+
class ResourceSelector(Protocol):
|
|
75
|
+
def matches(self, resource: Resource) -> bool: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
EventHandler = Callable[[object], Awaitable[None] | None]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@runtime_checkable
|
|
82
|
+
class MetricsSink(Protocol):
|
|
83
|
+
def increment(
|
|
84
|
+
self, name: str, value: int = 1, attributes: Mapping[str, str] | None = None
|
|
85
|
+
) -> None: ...
|
|
86
|
+
|
|
87
|
+
def observe(
|
|
88
|
+
self, name: str, value: float, attributes: Mapping[str, str] | None = None
|
|
89
|
+
) -> None: ...
|