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,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)
@@ -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,6 @@
1
+ """Provider-neutral lifecycle events."""
2
+
3
+ from refresh_engine.events.models import EventKind, RefreshEvent
4
+ from refresh_engine.events.publisher import EventPublisher
5
+
6
+ __all__ = ["EventKind", "EventPublisher", "RefreshEvent"]
@@ -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))