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.
Files changed (35) hide show
  1. refresh_engine/__init__.py +169 -0
  2. refresh_engine/api/__init__.py +43 -0
  3. refresh_engine/api/abc.py +176 -0
  4. refresh_engine/api/engine.py +468 -0
  5. refresh_engine/api/protocols.py +89 -0
  6. refresh_engine/core/models.py +362 -0
  7. refresh_engine/detection/__init__.py +21 -0
  8. refresh_engine/detection/detector.py +54 -0
  9. refresh_engine/detection/fingerprints.py +115 -0
  10. refresh_engine/errors.py +57 -0
  11. refresh_engine/events/__init__.py +6 -0
  12. refresh_engine/events/models.py +42 -0
  13. refresh_engine/events/publisher.py +77 -0
  14. refresh_engine/execution/__init__.py +5 -0
  15. refresh_engine/execution/coordinator.py +128 -0
  16. refresh_engine/execution/executor.py +180 -0
  17. refresh_engine/observability/__init__.py +9 -0
  18. refresh_engine/observability/logging.py +49 -0
  19. refresh_engine/observability/metrics.py +44 -0
  20. refresh_engine/planning/__init__.py +6 -0
  21. refresh_engine/planning/dependency.py +79 -0
  22. refresh_engine/planning/planner.py +86 -0
  23. refresh_engine/py.typed +0 -0
  24. refresh_engine/scheduling/__init__.py +13 -0
  25. refresh_engine/scheduling/async_scheduler.py +126 -0
  26. refresh_engine/scheduling/external.py +17 -0
  27. refresh_engine/stores/__init__.py +11 -0
  28. refresh_engine/stores/memory.py +77 -0
  29. refresh_engine/triggers/__init__.py +16 -0
  30. refresh_engine/triggers/adapters.py +38 -0
  31. refresh_engine/triggers/selectors.py +46 -0
  32. refresh_engine-0.1.0.dist-info/METADATA +95 -0
  33. refresh_engine-0.1.0.dist-info/RECORD +35 -0
  34. refresh_engine-0.1.0.dist-info/WHEEL +4 -0
  35. 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: ...