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,77 @@
1
+ """Ordered, subscriber-isolated lifecycle event publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import threading
8
+
9
+ from refresh_engine.api.abc import EventPublisherABC
10
+ from refresh_engine.api.protocols import EventHandler
11
+ from refresh_engine.events.models import RefreshEvent
12
+
13
+
14
+ class EventPublisher(EventPublisherABC):
15
+ """Queue ordered delivery without placing subscribers on the refresh path."""
16
+
17
+ def __init__(self) -> None:
18
+ self._handlers: list[EventHandler] = []
19
+ self._lock = threading.Lock()
20
+ self._errors: list[Exception] = []
21
+ self._tail: asyncio.Task[None] | None = None
22
+ self._tasks: set[asyncio.Task[None]] = set()
23
+
24
+ @property
25
+ def errors(self) -> tuple[Exception, ...]:
26
+ with self._lock:
27
+ return tuple(self._errors)
28
+
29
+ def subscribe(self, handler: EventHandler) -> None:
30
+ with self._lock:
31
+ if handler not in self._handlers:
32
+ self._handlers.append(handler)
33
+
34
+ def unsubscribe(self, handler: EventHandler) -> None:
35
+ with self._lock:
36
+ if handler in self._handlers:
37
+ self._handlers.remove(handler)
38
+
39
+ async def publish(self, event: RefreshEvent) -> None:
40
+ previous = self._tail
41
+ task = asyncio.create_task(
42
+ self._dispatch_after(previous, event),
43
+ name=f"refresh-event:{event.kind.value}",
44
+ )
45
+ self._tail = task
46
+ self._tasks.add(task)
47
+ task.add_done_callback(self._tasks.discard)
48
+ await asyncio.sleep(0)
49
+
50
+ async def drain(self) -> None:
51
+ """Wait until all events published so far have been delivered."""
52
+ tail = self._tail
53
+ if tail is not None:
54
+ await asyncio.shield(tail)
55
+
56
+ async def _dispatch_after(
57
+ self,
58
+ previous: asyncio.Task[None] | None,
59
+ event: RefreshEvent,
60
+ ) -> None:
61
+ if previous is not None:
62
+ await previous
63
+ with self._lock:
64
+ handlers = tuple(self._handlers)
65
+ for handler in handlers:
66
+ try:
67
+ if inspect.iscoroutinefunction(handler):
68
+ await handler(event)
69
+ else:
70
+ result = await asyncio.to_thread(handler, event)
71
+ if inspect.isawaitable(result):
72
+ await result
73
+ except asyncio.CancelledError:
74
+ raise
75
+ except Exception as error:
76
+ with self._lock:
77
+ self._errors.append(error)
@@ -0,0 +1,5 @@
1
+ """Refresh execution."""
2
+
3
+ from refresh_engine.execution.executor import RefreshExecutor
4
+
5
+ __all__ = ["RefreshExecutor"]
@@ -0,0 +1,128 @@
1
+ """Refresh request overlap coordination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass, field
8
+
9
+ from refresh_engine.core.models import (
10
+ OverlapPolicy,
11
+ RefreshRequest,
12
+ RefreshResult,
13
+ )
14
+
15
+ Runner = Callable[[RefreshRequest], Awaitable[RefreshResult]]
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _Pending:
20
+ request: RefreshRequest
21
+ futures: list[asyncio.Future[RefreshResult]] = field(default_factory=list)
22
+
23
+
24
+ class RefreshCoordinator:
25
+ """Serialize refreshes and apply an explicit overlap policy."""
26
+
27
+ def __init__(self, runner: Runner, policy: OverlapPolicy) -> None:
28
+ self._runner = runner
29
+ self._policy = policy
30
+ self._lock = asyncio.Lock()
31
+ self._pending: list[_Pending] = []
32
+ self._worker: asyncio.Task[None] | None = None
33
+ self._active: asyncio.Task[RefreshResult] | None = None
34
+ self._active_pending: _Pending | None = None
35
+ self._closed = False
36
+
37
+ async def submit(self, request: RefreshRequest) -> RefreshResult:
38
+ loop = asyncio.get_running_loop()
39
+ future: asyncio.Future[RefreshResult] = loop.create_future()
40
+ async with self._lock:
41
+ if self._closed:
42
+ raise RuntimeError("refresh coordinator is closed")
43
+ running = self._active is not None or bool(self._pending)
44
+ if running and self._policy is OverlapPolicy.SKIP_IF_RUNNING:
45
+ return RefreshResult.skipped("refresh already running")
46
+ if running and self._policy is OverlapPolicy.CANCEL_PREVIOUS:
47
+ if self._active is not None:
48
+ self._active.cancel()
49
+ self._cancel_pending()
50
+ if running and self._policy is OverlapPolicy.COALESCE and self._pending:
51
+ pending = self._pending[-1]
52
+ pending.request = pending.request.merge(request)
53
+ pending.futures.append(future)
54
+ else:
55
+ self._pending.append(_Pending(request, [future]))
56
+ if self._worker is None or self._worker.done():
57
+ self._worker = asyncio.create_task(
58
+ self._work(), name="refresh-engine-coordinator"
59
+ )
60
+ try:
61
+ return await future
62
+ except asyncio.CancelledError:
63
+ async with self._lock:
64
+ for pending in self._pending:
65
+ if future in pending.futures:
66
+ pending.futures.remove(future)
67
+ if (
68
+ self._active_pending is not None
69
+ and future in self._active_pending.futures
70
+ and all(item.done() for item in self._active_pending.futures)
71
+ and self._active is not None
72
+ ):
73
+ self._active.cancel()
74
+ raise
75
+
76
+ async def close(self, *, cancel_active: bool) -> None:
77
+ async with self._lock:
78
+ self._closed = True
79
+ if cancel_active and self._active is not None:
80
+ self._active.cancel()
81
+ if cancel_active:
82
+ self._cancel_pending()
83
+ worker = self._worker
84
+ if worker is not None:
85
+ await asyncio.gather(worker, return_exceptions=True)
86
+
87
+ async def _work(self) -> None:
88
+ while True:
89
+ async with self._lock:
90
+ if not self._pending:
91
+ self._active = None
92
+ return
93
+ pending = self._pending.pop(0)
94
+ self._active = asyncio.create_task(
95
+ self._run_one(pending.request),
96
+ name="refresh-engine-active-refresh",
97
+ )
98
+ self._active_pending = pending
99
+ active = self._active
100
+ try:
101
+ result = await active
102
+ except asyncio.CancelledError:
103
+ for future in pending.futures:
104
+ if not future.done():
105
+ future.cancel()
106
+ except Exception as error:
107
+ for future in pending.futures:
108
+ if not future.done():
109
+ future.set_exception(error)
110
+ else:
111
+ for future in pending.futures:
112
+ if not future.done():
113
+ future.set_result(result)
114
+ finally:
115
+ async with self._lock:
116
+ if self._active is active:
117
+ self._active = None
118
+ self._active_pending = None
119
+
120
+ async def _run_one(self, request: RefreshRequest) -> RefreshResult:
121
+ return await self._runner(request)
122
+
123
+ def _cancel_pending(self) -> None:
124
+ for pending in self._pending:
125
+ for future in pending.futures:
126
+ if not future.done():
127
+ future.cancel()
128
+ self._pending.clear()
@@ -0,0 +1,180 @@
1
+ """Bounded, retrying, dependency-ordered refresh execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping
7
+ from time import perf_counter
8
+
9
+ from tenacity import (
10
+ AsyncRetrying,
11
+ retry_if_exception_type,
12
+ stop_after_attempt,
13
+ wait_exponential,
14
+ wait_random,
15
+ )
16
+
17
+ from refresh_engine.api.protocols import RefreshOperation
18
+ from refresh_engine.core.models import (
19
+ FailurePolicy,
20
+ PlanAction,
21
+ PlanItem,
22
+ RefreshConfig,
23
+ RefreshIssue,
24
+ RefreshPlan,
25
+ Resource,
26
+ ResourceExecutionResult,
27
+ ResourceSnapshot,
28
+ )
29
+ from refresh_engine.events import EventKind, EventPublisher, RefreshEvent
30
+
31
+
32
+ class RefreshExecutor:
33
+ def __init__(
34
+ self,
35
+ operation: RefreshOperation,
36
+ config: RefreshConfig,
37
+ events: EventPublisher,
38
+ ) -> None:
39
+ self._operation = operation
40
+ self._config = config
41
+ self._events = events
42
+
43
+ async def execute(
44
+ self,
45
+ plan: RefreshPlan,
46
+ resources: Mapping[str, Resource],
47
+ snapshots: Mapping[str, ResourceSnapshot],
48
+ ) -> tuple[ResourceExecutionResult, ...]:
49
+ semaphore = asyncio.Semaphore(self._config.max_concurrency)
50
+ all_results: list[ResourceExecutionResult] = []
51
+ await self._events.publish(
52
+ RefreshEvent(EventKind.EXECUTION_STARTED, plan.refresh_id)
53
+ )
54
+ for level in plan.levels:
55
+ tasks = [
56
+ asyncio.create_task(
57
+ self._execute_item(plan, item, resources, snapshots, semaphore),
58
+ name=f"refresh:{plan.refresh_id}:{item.resource_id}",
59
+ )
60
+ for item in level
61
+ ]
62
+ try:
63
+ level_results = await asyncio.gather(*tasks)
64
+ except asyncio.CancelledError:
65
+ for task in tasks:
66
+ task.cancel()
67
+ await asyncio.gather(*tasks, return_exceptions=True)
68
+ raise
69
+ all_results.extend(level_results)
70
+ if self._config.failure_policy is FailurePolicy.FAIL_FAST and any(
71
+ not result.succeeded for result in level_results
72
+ ):
73
+ break
74
+ await self._events.publish(
75
+ RefreshEvent(EventKind.EXECUTION_COMPLETED, plan.refresh_id)
76
+ )
77
+ return tuple(all_results)
78
+
79
+ async def _execute_item(
80
+ self,
81
+ plan: RefreshPlan,
82
+ item: PlanItem,
83
+ resources: Mapping[str, Resource],
84
+ snapshots: Mapping[str, ResourceSnapshot],
85
+ semaphore: asyncio.Semaphore,
86
+ ) -> ResourceExecutionResult:
87
+ async with semaphore:
88
+ started = perf_counter()
89
+ await self._events.publish(
90
+ RefreshEvent(
91
+ EventKind.RESOURCE_REFRESH_STARTED,
92
+ plan.refresh_id,
93
+ item.resource_id,
94
+ )
95
+ )
96
+ attempts = 0
97
+ error: Exception | None = None
98
+ retry = self._config.retry
99
+ try:
100
+ if retry.max_attempts == 1:
101
+ attempts = 1
102
+ await self._run_operation(plan, item, resources, snapshots)
103
+ else:
104
+ async for attempt in AsyncRetrying(
105
+ stop=stop_after_attempt(retry.max_attempts),
106
+ wait=wait_exponential(
107
+ multiplier=retry.initial_delay,
108
+ max=retry.max_delay,
109
+ exp_base=retry.exponential_base,
110
+ )
111
+ + wait_random(0, retry.jitter),
112
+ retry=retry_if_exception_type(retry.retryable_exceptions),
113
+ reraise=True,
114
+ ):
115
+ attempts = attempt.retry_state.attempt_number
116
+ with attempt:
117
+ await self._run_operation(plan, item, resources, snapshots)
118
+ except asyncio.CancelledError:
119
+ raise
120
+ except Exception as caught:
121
+ error = caught
122
+ else:
123
+ await self._events.publish(
124
+ RefreshEvent(
125
+ EventKind.RESOURCE_REFRESH_COMPLETED,
126
+ plan.refresh_id,
127
+ item.resource_id,
128
+ )
129
+ )
130
+ return ResourceExecutionResult(
131
+ item.resource_id,
132
+ item.action,
133
+ True,
134
+ attempts,
135
+ perf_counter() - started,
136
+ )
137
+ assert error is not None
138
+ issue = RefreshIssue(
139
+ "execution",
140
+ str(error),
141
+ item.resource_id,
142
+ type(error).__name__,
143
+ )
144
+ await self._events.publish(
145
+ RefreshEvent(
146
+ EventKind.RESOURCE_REFRESH_FAILED,
147
+ plan.refresh_id,
148
+ item.resource_id,
149
+ attributes={"error_type": type(error).__name__},
150
+ )
151
+ )
152
+ return ResourceExecutionResult(
153
+ item.resource_id,
154
+ item.action,
155
+ False,
156
+ attempts,
157
+ perf_counter() - started,
158
+ issue,
159
+ )
160
+
161
+ async def _run_operation(
162
+ self,
163
+ plan: RefreshPlan,
164
+ item: PlanItem,
165
+ resources: Mapping[str, Resource],
166
+ snapshots: Mapping[str, ResourceSnapshot],
167
+ ) -> None:
168
+ resource = resources.get(item.resource_id)
169
+ if resource is None and item.action is PlanAction.DELETE:
170
+ resource = Resource(item.resource_id)
171
+ operation = self._operation(
172
+ resource,
173
+ snapshots.get(item.resource_id),
174
+ item.action,
175
+ plan.request,
176
+ )
177
+ if self._config.timeout.resource_refresh is None:
178
+ await operation
179
+ else:
180
+ await asyncio.wait_for(operation, self._config.timeout.resource_refresh)
@@ -0,0 +1,9 @@
1
+ """Logging and metrics hooks."""
2
+
3
+ from refresh_engine.observability.logging import (
4
+ StructlogEventHandler,
5
+ default_redactor,
6
+ )
7
+ from refresh_engine.observability.metrics import InMemoryMetrics
8
+
9
+ __all__ = ["InMemoryMetrics", "StructlogEventHandler", "default_redactor"]
@@ -0,0 +1,49 @@
1
+ """Safe lifecycle event logging through structlog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any
7
+
8
+ import structlog
9
+ from structlog.typing import FilteringBoundLogger
10
+
11
+ from refresh_engine.events.models import RefreshEvent
12
+
13
+ Redactor = Callable[[Mapping[str, Any]], Mapping[str, Any]]
14
+
15
+
16
+ def default_redactor(values: Mapping[str, Any]) -> Mapping[str, Any]:
17
+ sensitive = {"api_key", "authorization", "password", "secret", "token"}
18
+ return {
19
+ key: "[REDACTED]" if key.lower() in sensitive else value
20
+ for key, value in values.items()
21
+ }
22
+
23
+
24
+ class StructlogEventHandler:
25
+ """Translate refresh events into native structlog events.
26
+
27
+ The application retains ownership of global structlog processor and renderer
28
+ configuration. This handler only binds safe lifecycle fields.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ logger: FilteringBoundLogger | None = None,
34
+ redactor: Redactor = default_redactor,
35
+ ) -> None:
36
+ self._logger = logger or structlog.get_logger("refresh_engine")
37
+ self._redactor = redactor
38
+
39
+ def __call__(self, event: object) -> None:
40
+ if not isinstance(event, RefreshEvent):
41
+ return
42
+ fields = {
43
+ "refresh_id": event.refresh_id,
44
+ "resource_id": event.resource_id,
45
+ "event_kind": event.kind.value,
46
+ "timestamp": event.timestamp.isoformat(),
47
+ **event.attributes,
48
+ }
49
+ self._logger.info("refresh_event", **dict(self._redactor(fields)))
@@ -0,0 +1,44 @@
1
+ """Minimal provider-neutral metrics implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections import defaultdict
7
+ from collections.abc import Mapping
8
+
9
+ from refresh_engine.api.abc import MetricsSinkABC
10
+
11
+
12
+ class InMemoryMetrics(MetricsSinkABC):
13
+ def __init__(self) -> None:
14
+ self._counters: dict[str, int] = defaultdict(int)
15
+ self._observations: dict[str, list[float]] = defaultdict(list)
16
+ self._lock = threading.Lock()
17
+
18
+ def increment(
19
+ self, name: str, value: int = 1, attributes: Mapping[str, str] | None = None
20
+ ) -> None:
21
+ key = self._key(name, attributes)
22
+ with self._lock:
23
+ self._counters[key] += value
24
+
25
+ def observe(
26
+ self, name: str, value: float, attributes: Mapping[str, str] | None = None
27
+ ) -> None:
28
+ key = self._key(name, attributes)
29
+ with self._lock:
30
+ self._observations[key].append(value)
31
+
32
+ def snapshot(self) -> tuple[dict[str, int], dict[str, tuple[float, ...]]]:
33
+ with self._lock:
34
+ return (
35
+ dict(self._counters),
36
+ {key: tuple(values) for key, values in self._observations.items()},
37
+ )
38
+
39
+ @staticmethod
40
+ def _key(name: str, attributes: Mapping[str, str] | None) -> str:
41
+ if not attributes:
42
+ return name
43
+ suffix = ",".join(f"{key}={value}" for key, value in sorted(attributes.items()))
44
+ return f"{name}{{{suffix}}}"
@@ -0,0 +1,6 @@
1
+ """Dependency analysis and refresh planning."""
2
+
3
+ from refresh_engine.planning.dependency import DependencyGraph, ImpactAnalyzer
4
+ from refresh_engine.planning.planner import RefreshPlanner
5
+
6
+ __all__ = ["DependencyGraph", "ImpactAnalyzer", "RefreshPlanner"]
@@ -0,0 +1,79 @@
1
+ """Dependency graph and impact analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import cast
7
+
8
+ import networkx as nx
9
+
10
+ from refresh_engine.core.models import ChangeSet, ChangeType, ImpactSet
11
+ from refresh_engine.errors import DependencyCycleError
12
+
13
+
14
+ class DependencyGraph:
15
+ """Directed graph where a node maps to the resources it depends on."""
16
+
17
+ def __init__(self) -> None:
18
+ self._graph: nx.DiGraph[str] = nx.DiGraph()
19
+
20
+ @property
21
+ def nodes(self) -> frozenset[str]:
22
+ return frozenset(self._graph.nodes)
23
+
24
+ def add_resource(self, resource_id: str) -> None:
25
+ self._graph.add_node(resource_id)
26
+
27
+ def add_dependency(self, resource_id: str, dependency_id: str) -> None:
28
+ self._graph.add_edge(dependency_id, resource_id)
29
+
30
+ def remove_dependency(self, resource_id: str, dependency_id: str) -> None:
31
+ if self._graph.has_edge(dependency_id, resource_id):
32
+ self._graph.remove_edge(dependency_id, resource_id)
33
+
34
+ def dependencies_of(self, resource_id: str) -> frozenset[str]:
35
+ if resource_id not in self._graph:
36
+ return frozenset()
37
+ return frozenset(self._graph.predecessors(resource_id))
38
+
39
+ def dependents_of(self, resource_id: str) -> frozenset[str]:
40
+ if resource_id not in self._graph:
41
+ return frozenset()
42
+ return frozenset(self._graph.successors(resource_id))
43
+
44
+ def transitive_dependents(self, resource_ids: Iterable[str]) -> frozenset[str]:
45
+ selected = set(resource_ids)
46
+ impacted = set(selected)
47
+ for resource_id in selected & self.nodes:
48
+ impacted.update(cast(set[str], nx.descendants(self._graph, resource_id)))
49
+ return frozenset(impacted)
50
+
51
+ def topological_levels(
52
+ self, resource_ids: Iterable[str] | None = None
53
+ ) -> tuple[tuple[str, ...], ...]:
54
+ selected = self.nodes if resource_ids is None else frozenset(resource_ids)
55
+ graph = self._graph.subgraph(selected)
56
+ try:
57
+ generations = cast(
58
+ Iterable[Iterable[str]], nx.topological_generations(graph)
59
+ )
60
+ return tuple(tuple(sorted(generation)) for generation in generations)
61
+ except nx.NetworkXUnfeasible as error:
62
+ cycle_edges = cast(list[tuple[str, str]], nx.find_cycle(graph))
63
+ cycle = (*tuple(edge[0] for edge in cycle_edges), cycle_edges[0][0])
64
+ raise DependencyCycleError(cycle) from error
65
+
66
+
67
+ class ImpactAnalyzer:
68
+ def analyze(self, changes: ChangeSet, graph: DependencyGraph) -> ImpactSet:
69
+ direct = frozenset(
70
+ change.resource_id
71
+ for change in changes.changes
72
+ if change.change_type is not ChangeType.UNCHANGED
73
+ )
74
+ impacted = graph.transitive_dependents(direct)
75
+ return ImpactSet(
76
+ directly_changed=direct,
77
+ indirectly_impacted=impacted - direct,
78
+ unaffected=graph.nodes - impacted,
79
+ )
@@ -0,0 +1,86 @@
1
+ """Refresh plan construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from refresh_engine.core.models import (
8
+ ChangeSet,
9
+ ChangeType,
10
+ ImpactSet,
11
+ PlanAction,
12
+ PlanItem,
13
+ RefreshMode,
14
+ RefreshPlan,
15
+ RefreshRequest,
16
+ ResourceState,
17
+ )
18
+ from refresh_engine.planning.dependency import DependencyGraph
19
+
20
+
21
+ class RefreshPlanner:
22
+ def plan(
23
+ self,
24
+ refresh_id: str,
25
+ request: RefreshRequest,
26
+ changes: ChangeSet,
27
+ impact: ImpactSet,
28
+ graph: DependencyGraph,
29
+ current: Mapping[str, ResourceState],
30
+ ) -> RefreshPlan:
31
+ changed = {change.resource_id: change for change in changes.changes}
32
+ if request.mode is RefreshMode.FULL:
33
+ selected = set(current) | {
34
+ resource_id
35
+ for resource_id, change in changed.items()
36
+ if change.change_type is ChangeType.DELETED
37
+ }
38
+ elif request.mode is RefreshMode.TARGETED:
39
+ selected = set(request.resource_ids) & set(changed)
40
+ elif request.mode is RefreshMode.DEPENDENCY_AWARE:
41
+ selected = set(impact.all_impacted)
42
+ else:
43
+ selected = {
44
+ resource_id
45
+ for resource_id, change in changed.items()
46
+ if change.change_type is not ChangeType.UNCHANGED
47
+ }
48
+
49
+ deleted = {
50
+ resource_id
51
+ for resource_id in selected
52
+ if changed.get(resource_id)
53
+ and changed[resource_id].change_type is ChangeType.DELETED
54
+ }
55
+ refreshable = selected - deleted
56
+ levels = []
57
+ for level in graph.topological_levels(refreshable):
58
+ levels.append(
59
+ tuple(
60
+ PlanItem(
61
+ resource_id,
62
+ PlanAction.REFRESH,
63
+ current.get(resource_id),
64
+ graph.dependencies_of(resource_id),
65
+ )
66
+ for resource_id in level
67
+ )
68
+ )
69
+ if deleted:
70
+ levels.append(
71
+ tuple(
72
+ PlanItem(
73
+ resource_id,
74
+ PlanAction.DELETE,
75
+ None,
76
+ frozenset(),
77
+ )
78
+ for resource_id in sorted(deleted)
79
+ )
80
+ )
81
+ return RefreshPlan(
82
+ refresh_id,
83
+ request,
84
+ tuple(levels),
85
+ skipped_count=max(0, len(changes.changes) - len(selected)),
86
+ )
File without changes
@@ -0,0 +1,13 @@
1
+ """Background scheduler implementations."""
2
+
3
+ from refresh_engine.scheduling.async_scheduler import (
4
+ AsyncScheduler,
5
+ SchedulerState,
6
+ )
7
+ from refresh_engine.scheduling.external import ExternalSchedulerAdapter
8
+
9
+ __all__ = [
10
+ "AsyncScheduler",
11
+ "ExternalSchedulerAdapter",
12
+ "SchedulerState",
13
+ ]