generic-ml-cache-core 0.2.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.
- generic_ml_cache_core/__init__.py +64 -0
- generic_ml_cache_core/adapter/__init__.py +1 -0
- generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
- generic_ml_cache_core/adapter/inbound/composition.py +96 -0
- generic_ml_cache_core/adapter/out/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
- generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
- generic_ml_cache_core/adapter/out/client/claude.py +214 -0
- generic_ml_cache_core/adapter/out/client/codex.py +171 -0
- generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
- generic_ml_cache_core/adapter/out/client/discover.py +121 -0
- generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
- generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
- generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
- generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
- generic_ml_cache_core/adapter/out/client/registry.py +34 -0
- generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
- generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
- generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
- generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
- generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
- generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
- generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
- generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
- generic_ml_cache_core/application/__init__.py +1 -0
- generic_ml_cache_core/application/domain/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/client_status.py +17 -0
- generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
- generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
- generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
- generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
- generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
- generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
- generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
- generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
- generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
- generic_ml_cache_core/application/domain/model/model_info.py +20 -0
- generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
- generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
- generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
- generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
- generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
- generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
- generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
- generic_ml_cache_core/application/domain/model/run/message.py +20 -0
- generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
- generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
- generic_ml_cache_core/application/domain/service/__init__.py +1 -0
- generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
- generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
- generic_ml_cache_core/application/port/__init__.py +1 -0
- generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
- generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
- generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
- generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
- generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
- generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
- generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
- generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
- generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
- generic_ml_cache_core/application/port/out/__init__.py +1 -0
- generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
- generic_ml_cache_core/application/port/out/base.py +272 -0
- generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
- generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
- generic_ml_cache_core/application/port/out/clock_port.py +22 -0
- generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
- generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
- generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
- generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
- generic_ml_cache_core/application/usecase/__init__.py +1 -0
- generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
- generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
- generic_ml_cache_core/application/usecase/journal_events.py +19 -0
- generic_ml_cache_core/application/usecase/probe_service.py +44 -0
- generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
- generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
- generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
- generic_ml_cache_core/common/__init__.py +1 -0
- generic_ml_cache_core/common/checksum.py +82 -0
- generic_ml_cache_core/common/errors.py +76 -0
- generic_ml_cache_core/stream.py +65 -0
- generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
- generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
- generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
- generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
- generic_ml_cache_core-0.2.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The access registry: a side log of cache access events for observability.
|
|
4
|
+
|
|
5
|
+
It is **non-load-bearing by construction** -- it records *that* a hit / miss /
|
|
6
|
+
record / eviction happened, for `stats` and `prune` to read, but it never gates
|
|
7
|
+
correctness. Every operation swallows its own errors: if the database is missing,
|
|
8
|
+
locked, unwritable, or corrupt, the cache still resolves exactly as it would
|
|
9
|
+
without it. It is deliberately separate from the executions, which stay pure and
|
|
10
|
+
immutable -- no access counters are ever written back into a recording.
|
|
11
|
+
|
|
12
|
+
Stored in the store directory as ``registry.sqlite3`` (stdlib ``sqlite3`` only;
|
|
13
|
+
no third-party dependency). It carries no integrity/checksum role: a checksum kept
|
|
14
|
+
beside the data it guards, in a folder the user can write, protects nothing a
|
|
15
|
+
determined editor couldn't also rewrite -- so we don't pretend to.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sqlite3
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, Optional
|
|
24
|
+
|
|
25
|
+
# The access events. A resolve emits exactly one of HIT / MISS / RECORD
|
|
26
|
+
# (passthrough calls are outside cache accounting and emit nothing).
|
|
27
|
+
HIT = "hit"
|
|
28
|
+
MISS = "miss"
|
|
29
|
+
RECORD = "record"
|
|
30
|
+
|
|
31
|
+
_DB_NAME = "registry.sqlite3"
|
|
32
|
+
_SCHEMA = """
|
|
33
|
+
CREATE TABLE IF NOT EXISTS access_events (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
ts TEXT NOT NULL,
|
|
36
|
+
event TEXT NOT NULL,
|
|
37
|
+
match_key TEXT,
|
|
38
|
+
client TEXT,
|
|
39
|
+
model TEXT,
|
|
40
|
+
effort TEXT
|
|
41
|
+
)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AccessRegistry:
|
|
46
|
+
"""A best-effort SQLite log of access events, living beside the executions."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, root: Path) -> None:
|
|
49
|
+
self._path = Path(root) / _DB_NAME
|
|
50
|
+
|
|
51
|
+
def _connect(self) -> sqlite3.Connection:
|
|
52
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
conn = sqlite3.connect(self._path)
|
|
54
|
+
conn.execute(_SCHEMA)
|
|
55
|
+
return conn
|
|
56
|
+
|
|
57
|
+
def record(
|
|
58
|
+
self,
|
|
59
|
+
event: str,
|
|
60
|
+
*,
|
|
61
|
+
match_key: Optional[str],
|
|
62
|
+
client: str,
|
|
63
|
+
model: str,
|
|
64
|
+
effort: str,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Append one access event. Never raises -- failures are swallowed so the
|
|
67
|
+
cache is never affected by the registry being unavailable."""
|
|
68
|
+
try:
|
|
69
|
+
conn = self._connect()
|
|
70
|
+
try:
|
|
71
|
+
conn.execute(
|
|
72
|
+
"INSERT INTO access_events (ts, event, match_key, client, model, effort) "
|
|
73
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
74
|
+
(
|
|
75
|
+
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
76
|
+
event,
|
|
77
|
+
match_key,
|
|
78
|
+
client,
|
|
79
|
+
model,
|
|
80
|
+
effort,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
conn.commit()
|
|
84
|
+
finally:
|
|
85
|
+
conn.close()
|
|
86
|
+
except Exception:
|
|
87
|
+
# Non-load-bearing: observability must never break the cache.
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def hit_counts_by_key(self) -> Dict[str, int]:
|
|
91
|
+
"""Return {match_key: number-of-hits} across all recorded HIT events
|
|
92
|
+
({} if unavailable).
|
|
93
|
+
|
|
94
|
+
A hit is a real call that was *not* made because a stored execution answered it,
|
|
95
|
+
so multiplying an execution's recorded usage by its hit count is exactly the
|
|
96
|
+
usage that hit saved. Best-effort like everything here: never raises.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
conn = self._connect()
|
|
100
|
+
try:
|
|
101
|
+
rows = conn.execute(
|
|
102
|
+
"SELECT match_key, COUNT(*) FROM access_events "
|
|
103
|
+
"WHERE event = ? AND match_key IS NOT NULL GROUP BY match_key",
|
|
104
|
+
(HIT,),
|
|
105
|
+
).fetchall()
|
|
106
|
+
return {key: int(count) for key, count in rows}
|
|
107
|
+
finally:
|
|
108
|
+
conn.close()
|
|
109
|
+
except Exception:
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
def event_counts(self) -> Dict[str, int]:
|
|
113
|
+
"""Return {event: count} across all recorded events ({} if unavailable)."""
|
|
114
|
+
try:
|
|
115
|
+
conn = self._connect()
|
|
116
|
+
try:
|
|
117
|
+
rows = conn.execute(
|
|
118
|
+
"SELECT event, COUNT(*) FROM access_events GROUP BY event"
|
|
119
|
+
).fetchall()
|
|
120
|
+
return {event: count for event, count in rows}
|
|
121
|
+
finally:
|
|
122
|
+
conn.close()
|
|
123
|
+
except Exception:
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
def last_access(self) -> Dict[str, float]:
|
|
127
|
+
"""Return {match_key: latest-event epoch seconds} for LRU eviction ordering
|
|
128
|
+
({} if unavailable). An execution absent here has never been seen by the
|
|
129
|
+
registry; the caller falls back to file age for it."""
|
|
130
|
+
try:
|
|
131
|
+
conn = self._connect()
|
|
132
|
+
try:
|
|
133
|
+
rows = conn.execute(
|
|
134
|
+
"SELECT match_key, MAX(ts) FROM access_events "
|
|
135
|
+
"WHERE match_key IS NOT NULL GROUP BY match_key"
|
|
136
|
+
).fetchall()
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
except Exception:
|
|
140
|
+
return {}
|
|
141
|
+
out: Dict[str, float] = {}
|
|
142
|
+
for key, ts in rows:
|
|
143
|
+
try:
|
|
144
|
+
out[key] = datetime.fromisoformat(ts).timestamp()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
return out
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""JournalMetrics: the MetricsPort over the SQLite access registry."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.adapter.out.metrics.access_registry import AccessRegistry
|
|
10
|
+
from generic_ml_cache_core.application.port.out.metrics_port import MetricsPort
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JournalMetrics(MetricsPort):
|
|
14
|
+
"""Implements the journal over the existing best-effort access registry.
|
|
15
|
+
|
|
16
|
+
Non-load-bearing by construction: the registry swallows its own errors, so
|
|
17
|
+
``record_event`` never raises and the projections return empty on failure —
|
|
18
|
+
observability never breaks an execution. This adapter only maps the port's
|
|
19
|
+
``execution_key`` onto the registry's ``match_key``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, registry: AccessRegistry) -> None:
|
|
23
|
+
self._registry = registry
|
|
24
|
+
|
|
25
|
+
def record_event(
|
|
26
|
+
self,
|
|
27
|
+
event: str,
|
|
28
|
+
*,
|
|
29
|
+
execution_key: Optional[str],
|
|
30
|
+
client: str,
|
|
31
|
+
model: str,
|
|
32
|
+
effort: str,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._registry.record(
|
|
35
|
+
event, match_key=execution_key, client=client, model=model, effort=effort
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def hit_counts_by_key(self) -> Dict[str, int]:
|
|
39
|
+
return self._registry.hit_counts_by_key()
|
|
40
|
+
|
|
41
|
+
def event_counts(self) -> Dict[str, int]:
|
|
42
|
+
return self._registry.event_counts()
|
|
43
|
+
|
|
44
|
+
def last_access(self) -> Dict[str, float]:
|
|
45
|
+
return self._registry.last_access()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hexagonal layer package."""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Serialize a polymorphic CallIdentity for the SQLite repository.
|
|
4
|
+
|
|
5
|
+
The hybrid persistence (domain-model §3): the queryable fields (kind, client,
|
|
6
|
+
model, effort) become real columns; the divergent/opaque fields ride in a JSON
|
|
7
|
+
column. This pair maps each CallIdentity subclass to that row shape and back. It
|
|
8
|
+
lives in the adapter — the domain identities know nothing about the database.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from generic_ml_cache_core.application.domain.model.identity.api_call_identity import (
|
|
17
|
+
ApiCallIdentity,
|
|
18
|
+
)
|
|
19
|
+
from generic_ml_cache_core.application.domain.model.identity.call_identity import CallIdentity
|
|
20
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
|
|
21
|
+
from generic_ml_cache_core.application.domain.model.identity.managed_call_identity import (
|
|
22
|
+
ManagedCallIdentity,
|
|
23
|
+
)
|
|
24
|
+
from generic_ml_cache_core.application.domain.model.identity.passthrough_call_identity import (
|
|
25
|
+
PassthroughCallIdentity,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SerializedIdentity:
|
|
31
|
+
"""The row shape: denormalized query columns + the serialized remainder."""
|
|
32
|
+
|
|
33
|
+
kind: str
|
|
34
|
+
client: str
|
|
35
|
+
model: str
|
|
36
|
+
effort: str
|
|
37
|
+
identity_json: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def serialize_identity(identity: CallIdentity) -> SerializedIdentity:
|
|
41
|
+
if isinstance(identity, ManagedCallIdentity):
|
|
42
|
+
return SerializedIdentity(
|
|
43
|
+
kind=ExecutionKind.LOCAL_MANAGED.value,
|
|
44
|
+
client=identity.client,
|
|
45
|
+
model=identity.model,
|
|
46
|
+
effort=identity.effort,
|
|
47
|
+
identity_json=json.dumps(
|
|
48
|
+
{
|
|
49
|
+
"context_fingerprint": identity.context_fingerprint,
|
|
50
|
+
"prompt_fingerprint": identity.prompt_fingerprint,
|
|
51
|
+
"input_file_fingerprints": identity.input_file_fingerprints,
|
|
52
|
+
"client_args_fingerprint": identity.client_args_fingerprint,
|
|
53
|
+
"grants": sorted(identity.grants),
|
|
54
|
+
}
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
if isinstance(identity, PassthroughCallIdentity):
|
|
58
|
+
return SerializedIdentity(
|
|
59
|
+
kind=ExecutionKind.LOCAL_PASSTHROUGH.value,
|
|
60
|
+
client=identity.client,
|
|
61
|
+
model="",
|
|
62
|
+
effort="",
|
|
63
|
+
identity_json=json.dumps({"native_args_fingerprint": identity.native_args_fingerprint}),
|
|
64
|
+
)
|
|
65
|
+
if isinstance(identity, ApiCallIdentity):
|
|
66
|
+
return SerializedIdentity(
|
|
67
|
+
kind=ExecutionKind.API.value,
|
|
68
|
+
client=identity.provider,
|
|
69
|
+
model=identity.model,
|
|
70
|
+
effort="",
|
|
71
|
+
identity_json=json.dumps({"messages_fingerprint": identity.messages_fingerprint}),
|
|
72
|
+
)
|
|
73
|
+
raise ValueError(f"cannot serialize unknown call identity type: {type(identity).__name__}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def deserialize_identity(serialized: SerializedIdentity) -> CallIdentity:
|
|
77
|
+
fields = json.loads(serialized.identity_json)
|
|
78
|
+
if serialized.kind == ExecutionKind.LOCAL_MANAGED.value:
|
|
79
|
+
return ManagedCallIdentity(
|
|
80
|
+
client=serialized.client,
|
|
81
|
+
model=serialized.model,
|
|
82
|
+
effort=serialized.effort,
|
|
83
|
+
context_fingerprint=fields["context_fingerprint"],
|
|
84
|
+
prompt_fingerprint=fields["prompt_fingerprint"],
|
|
85
|
+
input_file_fingerprints=dict(fields["input_file_fingerprints"]),
|
|
86
|
+
client_args_fingerprint=fields["client_args_fingerprint"],
|
|
87
|
+
grants=frozenset(fields["grants"]),
|
|
88
|
+
)
|
|
89
|
+
if serialized.kind == ExecutionKind.LOCAL_PASSTHROUGH.value:
|
|
90
|
+
return PassthroughCallIdentity(
|
|
91
|
+
client=serialized.client,
|
|
92
|
+
native_args_fingerprint=fields["native_args_fingerprint"],
|
|
93
|
+
)
|
|
94
|
+
if serialized.kind == ExecutionKind.API.value:
|
|
95
|
+
return ApiCallIdentity(
|
|
96
|
+
provider=serialized.client,
|
|
97
|
+
model=serialized.model,
|
|
98
|
+
messages_fingerprint=fields["messages_fingerprint"],
|
|
99
|
+
)
|
|
100
|
+
raise ValueError(f"cannot deserialize unknown identity kind: {serialized.kind!r}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""InMemoryExecutionRepository: an ephemeral, append-only execution store."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_state import ExecutionState
|
|
11
|
+
from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
|
|
12
|
+
from generic_ml_cache_core.application.port.out.clock_port import ClockPort
|
|
13
|
+
from generic_ml_cache_core.application.port.out.execution_repository_port import (
|
|
14
|
+
ExecutionRepositoryPort,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InMemoryExecutionRepository(ExecutionRepositoryPort):
|
|
19
|
+
"""An in-memory, append-only implementation of the execution repository.
|
|
20
|
+
|
|
21
|
+
Holds only structure: every saved execution is dehydrated (artifact bytes
|
|
22
|
+
dropped) before storage, so the repository never carries output content —
|
|
23
|
+
the bytes live in the blob store. Suitable as an ephemeral cache for a
|
|
24
|
+
library consumer and as a faithful test double that forces the use case down
|
|
25
|
+
the same hydrate-from-blob path the durable adapter will.
|
|
26
|
+
|
|
27
|
+
The clock is injected (it stamps supersession), so time is deterministic.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, clock: ClockPort) -> None:
|
|
31
|
+
self._clock = clock
|
|
32
|
+
self._by_key: Dict[str, List[MlExecution]] = {}
|
|
33
|
+
|
|
34
|
+
def find_current(self, execution_key: str) -> Optional[MlExecution]:
|
|
35
|
+
for execution in self._by_key.get(execution_key, []):
|
|
36
|
+
if self._is_servable(execution):
|
|
37
|
+
return replace(execution)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def find_all(self, execution_key: str) -> List[MlExecution]:
|
|
41
|
+
return [replace(execution) for execution in self._by_key.get(execution_key, [])]
|
|
42
|
+
|
|
43
|
+
def save(self, execution: MlExecution) -> None:
|
|
44
|
+
execution_key = execution.call_identity.generate_key()
|
|
45
|
+
stored = self._dehydrate(execution)
|
|
46
|
+
history = self._by_key.setdefault(execution_key, [])
|
|
47
|
+
if self._is_servable(stored):
|
|
48
|
+
superseded_at = self._clock.now()
|
|
49
|
+
for prior in history:
|
|
50
|
+
if self._is_servable(prior):
|
|
51
|
+
prior.superseded_at = superseded_at
|
|
52
|
+
history.append(stored)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _is_servable(execution: MlExecution) -> bool:
|
|
56
|
+
"""A servable execution is the current cached answer: a persisted success
|
|
57
|
+
that has not been superseded."""
|
|
58
|
+
return (
|
|
59
|
+
execution.execution_state is ExecutionState.SUCCESS
|
|
60
|
+
and execution.output_persisted
|
|
61
|
+
and execution.superseded_at is None
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _dehydrate(execution: MlExecution) -> MlExecution:
|
|
66
|
+
"""Return a copy whose artifacts carry no bytes — the repository stores
|
|
67
|
+
structure only; the bytes belong to the blob store."""
|
|
68
|
+
dehydrated_artifacts = [replace(artifact, content=None) for artifact in execution.artifacts]
|
|
69
|
+
return replace(execution, artifacts=dehydrated_artifacts)
|