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,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,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,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
|
+
)
|
refresh_engine/py.typed
ADDED
|
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
|
+
]
|