specter-runtime 0.1.1__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.
- specter/__init__.py +136 -0
- specter/boot.py +53 -0
- specter/core/__init__.py +37 -0
- specter/core/bus.py +163 -0
- specter/core/cache.py +301 -0
- specter/core/controller.py +337 -0
- specter/core/handler.py +259 -0
- specter/core/lifecycle.py +659 -0
- specter/core/manager.py +379 -0
- specter/core/model.py +285 -0
- specter/core/operation.py +84 -0
- specter/core/outcome.py +58 -0
- specter/core/ownership.py +55 -0
- specter/core/process.py +270 -0
- specter/core/queue_service.py +122 -0
- specter/core/registry.py +275 -0
- specter/core/schema.py +373 -0
- specter/core/socket_ingress.py +335 -0
- specter/core/store.py +207 -0
- specter/core/watcher.py +327 -0
- specter/http.py +165 -0
- specter/router.py +200 -0
- specter_runtime-0.1.1.dist-info/METADATA +174 -0
- specter_runtime-0.1.1.dist-info/RECORD +28 -0
- specter_runtime-0.1.1.dist-info/WHEEL +5 -0
- specter_runtime-0.1.1.dist-info/licenses/LICENSE +201 -0
- specter_runtime-0.1.1.dist-info/licenses/NOTICE +8 -0
- specter_runtime-0.1.1.dist-info/top_level.txt +1 -0
specter/__init__.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Copyright 2026 BleedingXiko
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
SPECTER — Service Primitives for Event Control, Teardown, and Execution Runtime
|
|
17
|
+
================================================================================
|
|
18
|
+
|
|
19
|
+
Lifecycle-first backend framework for Python, Flask, and gevent.
|
|
20
|
+
Provides explicit ownership, deterministic cleanup, and clear service
|
|
21
|
+
boundaries for Flask/gevent/SQLite applications.
|
|
22
|
+
|
|
23
|
+
**Framework Surface:**
|
|
24
|
+
|
|
25
|
+
+--------------------+----------------------------------+
|
|
26
|
+
| Channel | Role |
|
|
27
|
+
+====================+==================================+
|
|
28
|
+
| ``Service`` | Background lifecycle owner |
|
|
29
|
+
+--------------------+----------------------------------+
|
|
30
|
+
| ``QueueService`` | Queue-backed worker lifecycle |
|
|
31
|
+
+--------------------+----------------------------------+
|
|
32
|
+
| ``Controller`` | Feature composition root |
|
|
33
|
+
+--------------------+----------------------------------+
|
|
34
|
+
| ``Handler`` | Socket event lifecycle owner |
|
|
35
|
+
+--------------------+----------------------------------+
|
|
36
|
+
| ``create_model`` | Structured state graph |
|
|
37
|
+
+--------------------+----------------------------------+
|
|
38
|
+
| ``create_store`` | Shared mutable state |
|
|
39
|
+
+--------------------+----------------------------------+
|
|
40
|
+
| ``create_cache`` | Shared state with TTL + cascade |
|
|
41
|
+
+--------------------+----------------------------------+
|
|
42
|
+
| ``ManagedProcess`` | Subprocess + stream ownership |
|
|
43
|
+
+--------------------+----------------------------------+
|
|
44
|
+
| ``Watcher`` | Observation loop primitive |
|
|
45
|
+
+--------------------+----------------------------------+
|
|
46
|
+
| ``Schema`` | Payload contract / validation |
|
|
47
|
+
+--------------------+----------------------------------+
|
|
48
|
+
| ``Outcome`` | Structured operation result |
|
|
49
|
+
+--------------------+----------------------------------+
|
|
50
|
+
| ``Operation`` | Structured backend action |
|
|
51
|
+
+--------------------+----------------------------------+
|
|
52
|
+
| ``Router`` | Class-based HTTP composition |
|
|
53
|
+
+--------------------+----------------------------------+
|
|
54
|
+
| ``registry`` | Composition-root service locator |
|
|
55
|
+
+--------------------+----------------------------------+
|
|
56
|
+
| ``bus`` | Internal pub/sub |
|
|
57
|
+
+--------------------+----------------------------------+
|
|
58
|
+
| ``json_endpoint`` | Flask route envelope helpers |
|
|
59
|
+
+--------------------+----------------------------------+
|
|
60
|
+
|
|
61
|
+
**Import from here:**
|
|
62
|
+
|
|
63
|
+
from specter import (
|
|
64
|
+
Service, QueueService, Controller, Handler, create_store,
|
|
65
|
+
create_cache, Watcher, Schema, Field,
|
|
66
|
+
registry, bus, HTTPError, json_endpoint, expect_json,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
See ``specter.md`` for the full guide.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# Core primitives
|
|
73
|
+
from .core.lifecycle import Service
|
|
74
|
+
from .core.queue_service import QueueService
|
|
75
|
+
from .core.controller import Controller
|
|
76
|
+
from .core.process import ManagedProcess, start_process
|
|
77
|
+
from .core.watcher import Watcher, WatcherError
|
|
78
|
+
from .core.model import Model, create_model
|
|
79
|
+
from .core.schema import Schema, Field, SchemaError
|
|
80
|
+
from .core.outcome import Outcome
|
|
81
|
+
from .core.operation import Operation, OperationError
|
|
82
|
+
from .core.store import Store, create_store
|
|
83
|
+
from .core.handler import Handler
|
|
84
|
+
from .core.cache import Cache, create_cache
|
|
85
|
+
from .core.bus import EventBus, bus
|
|
86
|
+
from .core.registry import SPECTERRegistry, registry
|
|
87
|
+
from .core.manager import ServiceManager
|
|
88
|
+
from .core.socket_ingress import SocketIngress
|
|
89
|
+
from .http import HTTPError, json_endpoint, expect_json, require_fields
|
|
90
|
+
from .router import Router, route
|
|
91
|
+
from .boot import boot
|
|
92
|
+
|
|
93
|
+
__version__ = '0.1.1'
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
# Lifecycle
|
|
97
|
+
'Service',
|
|
98
|
+
'QueueService',
|
|
99
|
+
'Controller',
|
|
100
|
+
'ManagedProcess',
|
|
101
|
+
'start_process',
|
|
102
|
+
'Watcher',
|
|
103
|
+
'WatcherError',
|
|
104
|
+
'Outcome',
|
|
105
|
+
'Operation',
|
|
106
|
+
'OperationError',
|
|
107
|
+
'Router',
|
|
108
|
+
'route',
|
|
109
|
+
'Handler',
|
|
110
|
+
'ServiceManager',
|
|
111
|
+
'SocketIngress',
|
|
112
|
+
'boot',
|
|
113
|
+
# State
|
|
114
|
+
'Model',
|
|
115
|
+
'create_model',
|
|
116
|
+
'Store',
|
|
117
|
+
'create_store',
|
|
118
|
+
'Cache',
|
|
119
|
+
'create_cache',
|
|
120
|
+
# Contracts
|
|
121
|
+
'Schema',
|
|
122
|
+
'Field',
|
|
123
|
+
'SchemaError',
|
|
124
|
+
# Communication
|
|
125
|
+
'EventBus',
|
|
126
|
+
'bus',
|
|
127
|
+
# Registry
|
|
128
|
+
'SPECTERRegistry',
|
|
129
|
+
'registry',
|
|
130
|
+
# HTTP
|
|
131
|
+
'HTTPError',
|
|
132
|
+
'json_endpoint',
|
|
133
|
+
'expect_json',
|
|
134
|
+
'require_fields',
|
|
135
|
+
'__version__',
|
|
136
|
+
]
|
specter/boot.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright 2026 BleedingXiko
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""SPECTER boot helpers for app-level composition roots."""
|
|
16
|
+
|
|
17
|
+
from .core.manager import ServiceManager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def boot(
|
|
21
|
+
app,
|
|
22
|
+
socketio,
|
|
23
|
+
*,
|
|
24
|
+
services=None,
|
|
25
|
+
handlers=None,
|
|
26
|
+
controllers=None,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Create and boot a ``ServiceManager`` with the supplied components.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
app: Flask app instance.
|
|
33
|
+
socketio: Flask-SocketIO instance.
|
|
34
|
+
services: Optional iterable of ``Service`` instances.
|
|
35
|
+
handlers: Optional iterable of handler instances.
|
|
36
|
+
controllers: Optional iterable of ``Controller`` instances.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A booted ``ServiceManager``.
|
|
40
|
+
"""
|
|
41
|
+
manager = ServiceManager(app, socketio)
|
|
42
|
+
|
|
43
|
+
for service in services or ():
|
|
44
|
+
manager.register_service(service)
|
|
45
|
+
|
|
46
|
+
for handler in handlers or ():
|
|
47
|
+
manager.register_handler(handler)
|
|
48
|
+
|
|
49
|
+
for controller in controllers or ():
|
|
50
|
+
manager.register_controller(controller)
|
|
51
|
+
|
|
52
|
+
manager.boot()
|
|
53
|
+
return manager
|
specter/core/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright 2026 BleedingXiko
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""SPECTER Core — internal primitives."""
|
|
16
|
+
|
|
17
|
+
from .lifecycle import Service
|
|
18
|
+
from .queue_service import QueueService
|
|
19
|
+
from .process import ManagedProcess, start_process
|
|
20
|
+
from .model import Model, create_model
|
|
21
|
+
from .outcome import Outcome
|
|
22
|
+
from .operation import Operation, OperationError
|
|
23
|
+
from .store import Store, create_store
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'Service',
|
|
27
|
+
'QueueService',
|
|
28
|
+
'ManagedProcess',
|
|
29
|
+
'start_process',
|
|
30
|
+
'Model',
|
|
31
|
+
'create_model',
|
|
32
|
+
'Outcome',
|
|
33
|
+
'Operation',
|
|
34
|
+
'OperationError',
|
|
35
|
+
'Store',
|
|
36
|
+
'create_store',
|
|
37
|
+
]
|
specter/core/bus.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Copyright 2026 BleedingXiko
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
SPECTER EventBus — Internal pub/sub for cross-service communication.
|
|
17
|
+
|
|
18
|
+
Decoupled broadcast semantics so services can react to events without
|
|
19
|
+
importing each other directly.
|
|
20
|
+
|
|
21
|
+
NOTE: This bus is for **internal server-side** events only.
|
|
22
|
+
Socket events go through ``socketio.emit()``.
|
|
23
|
+
|
|
24
|
+
Concurrency model:
|
|
25
|
+
Subscribers run synchronously in the emitter's greenlet.
|
|
26
|
+
If a subscriber needs to do heavy work, it should spawn its own
|
|
27
|
+
greenlet via ``gevent.spawn()``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventBus:
|
|
36
|
+
"""Global event bus for decoupled service communication."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._events = {} # event_name -> set of callbacks
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Subscribe
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def on(self, event, callback):
|
|
46
|
+
"""
|
|
47
|
+
Subscribe to an event.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
event: Event name string.
|
|
51
|
+
callback: Function to call when event is emitted. Receives
|
|
52
|
+
a single ``data`` argument (may be ``None``).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
An unsubscribe function.
|
|
56
|
+
"""
|
|
57
|
+
if not callable(callback):
|
|
58
|
+
raise TypeError(
|
|
59
|
+
f"[SPECTER:bus] on('{event}'): callback must be callable, "
|
|
60
|
+
f"got {type(callback).__name__}"
|
|
61
|
+
)
|
|
62
|
+
if event not in self._events:
|
|
63
|
+
self._events[event] = set()
|
|
64
|
+
self._events[event].add(callback)
|
|
65
|
+
return lambda: self.off(event, callback)
|
|
66
|
+
|
|
67
|
+
def off(self, event, callback):
|
|
68
|
+
"""
|
|
69
|
+
Unsubscribe from an event.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
event: Event name
|
|
73
|
+
callback: The exact callback reference passed to ``on()``
|
|
74
|
+
"""
|
|
75
|
+
listeners = self._events.get(event)
|
|
76
|
+
if listeners:
|
|
77
|
+
listeners.discard(callback)
|
|
78
|
+
if not listeners:
|
|
79
|
+
del self._events[event]
|
|
80
|
+
|
|
81
|
+
def once(self, event, callback):
|
|
82
|
+
"""
|
|
83
|
+
Subscribe to an event exactly once — auto-unsubscribes after
|
|
84
|
+
the first call.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
event: Event name
|
|
88
|
+
callback: Function to call once
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
An unsubscribe function (no-op after first call).
|
|
92
|
+
"""
|
|
93
|
+
def wrapper(data=None):
|
|
94
|
+
self.off(event, wrapper)
|
|
95
|
+
callback(data)
|
|
96
|
+
|
|
97
|
+
return self.on(event, wrapper)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Emit
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def emit(self, event, data=None):
|
|
104
|
+
"""
|
|
105
|
+
Emit an event to all subscribers.
|
|
106
|
+
|
|
107
|
+
Subscribers run synchronously in the caller's greenlet. If a
|
|
108
|
+
subscriber raises, the error is logged and remaining subscribers
|
|
109
|
+
still execute.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
event: Event name
|
|
113
|
+
data: Optional payload passed to every listener
|
|
114
|
+
"""
|
|
115
|
+
listeners = self._events.get(event)
|
|
116
|
+
if not listeners:
|
|
117
|
+
return
|
|
118
|
+
# Iterate over a snapshot so subscribers can unsub during iteration
|
|
119
|
+
for callback in list(listeners):
|
|
120
|
+
try:
|
|
121
|
+
callback(data)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(
|
|
124
|
+
f"[SPECTER:bus] Error in listener for '{event}': {e}",
|
|
125
|
+
exc_info=True,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Housekeeping
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def clear(self, event=None):
|
|
133
|
+
"""
|
|
134
|
+
Clear listeners.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
event: If provided, clear only listeners for this event.
|
|
138
|
+
If ``None``, clear **all** events.
|
|
139
|
+
"""
|
|
140
|
+
if event:
|
|
141
|
+
self._events.pop(event, None)
|
|
142
|
+
else:
|
|
143
|
+
self._events.clear()
|
|
144
|
+
|
|
145
|
+
def has_listeners(self, event):
|
|
146
|
+
"""Return ``True`` if the event has at least one subscriber."""
|
|
147
|
+
return bool(self._events.get(event))
|
|
148
|
+
|
|
149
|
+
def listener_count(self, event=None):
|
|
150
|
+
"""
|
|
151
|
+
Return listener count.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
event: If provided, count for that event only.
|
|
155
|
+
If ``None``, count all listeners across all events.
|
|
156
|
+
"""
|
|
157
|
+
if event:
|
|
158
|
+
return len(self._events.get(event, set()))
|
|
159
|
+
return sum(len(s) for s in self._events.values())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Singleton — the one bus to rule them all.
|
|
163
|
+
bus = EventBus()
|
specter/core/cache.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Copyright 2026 BleedingXiko
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
SPECTER Cache — Shared state with TTL and dependency-based invalidation.
|
|
17
|
+
|
|
18
|
+
Shared state with TTL expiry, gevent-safe locking, and automatic cache
|
|
19
|
+
cascade through the bus.
|
|
20
|
+
|
|
21
|
+
Dependency cascade:
|
|
22
|
+
When a cache with ``depends_on=['storage:mounts']`` is created, it
|
|
23
|
+
auto-subscribes to that bus event. When that event fires, the cache
|
|
24
|
+
invalidates itself and emits ``'{name}:invalidated'`` on the bus —
|
|
25
|
+
which in turn triggers any caches that depend on *it*.
|
|
26
|
+
|
|
27
|
+
Example chain:
|
|
28
|
+
storage:mounts → drives_cache invalidates
|
|
29
|
+
→ emits 'drives:invalidated'
|
|
30
|
+
→ categories_cache invalidates
|
|
31
|
+
→ emits 'categories:invalidated'
|
|
32
|
+
→ hidden_files_cache invalidates
|
|
33
|
+
|
|
34
|
+
Concurrency:
|
|
35
|
+
All read/write operations are protected by a ``BoundedSemaphore``
|
|
36
|
+
(gevent-aware). Never use ``threading.Lock``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import time
|
|
40
|
+
import logging
|
|
41
|
+
from gevent.lock import BoundedSemaphore
|
|
42
|
+
|
|
43
|
+
from .bus import bus
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Cache:
|
|
49
|
+
"""
|
|
50
|
+
Named cache with TTL and dependency-based cascade invalidation.
|
|
51
|
+
|
|
52
|
+
Do not instantiate directly — use :func:`create_cache`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, name, ttl=0, depends_on=None):
|
|
56
|
+
"""
|
|
57
|
+
Args:
|
|
58
|
+
name: Unique cache name (used in bus events and logging).
|
|
59
|
+
ttl: Time-to-live in seconds. 0 means no auto-expiry.
|
|
60
|
+
depends_on: List of bus event names that trigger invalidation.
|
|
61
|
+
"""
|
|
62
|
+
self.name = name
|
|
63
|
+
self._ttl = max(0.0, float(ttl or 0))
|
|
64
|
+
self._entry_ttl = self._ttl
|
|
65
|
+
self._value = None
|
|
66
|
+
self._updated_at = 0
|
|
67
|
+
self._lock = BoundedSemaphore(1)
|
|
68
|
+
self._invalidate_callbacks = []
|
|
69
|
+
self._bus_unsubs = []
|
|
70
|
+
|
|
71
|
+
# Subscribe to dependency events
|
|
72
|
+
if depends_on:
|
|
73
|
+
for event in depends_on:
|
|
74
|
+
unsub = bus.on(event, self._on_dependency_invalidated)
|
|
75
|
+
self._bus_unsubs.append(unsub)
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Read
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def get(self, default=None):
|
|
82
|
+
"""
|
|
83
|
+
Return cached value, or ``default`` if expired/empty.
|
|
84
|
+
|
|
85
|
+
This is a non-blocking read protected by a gevent lock.
|
|
86
|
+
"""
|
|
87
|
+
with self._lock:
|
|
88
|
+
if self._value is None:
|
|
89
|
+
return default
|
|
90
|
+
if (
|
|
91
|
+
self._entry_ttl > 0 and
|
|
92
|
+
(time.time() - self._updated_at) >= self._entry_ttl
|
|
93
|
+
):
|
|
94
|
+
# Expired — clear in place
|
|
95
|
+
self._value = None
|
|
96
|
+
self._updated_at = 0
|
|
97
|
+
self._entry_ttl = self._ttl
|
|
98
|
+
return default
|
|
99
|
+
return self._value
|
|
100
|
+
|
|
101
|
+
def is_valid(self):
|
|
102
|
+
"""Return ``True`` if the cache has a non-expired value."""
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._value is None:
|
|
105
|
+
return False
|
|
106
|
+
if (
|
|
107
|
+
self._entry_ttl > 0 and
|
|
108
|
+
(time.time() - self._updated_at) >= self._entry_ttl
|
|
109
|
+
):
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def updated_at(self):
|
|
115
|
+
"""Timestamp of the last ``set()`` call (epoch seconds)."""
|
|
116
|
+
return self._updated_at
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def ttl(self):
|
|
120
|
+
"""Configured TTL in seconds. 0 means no auto-expiry."""
|
|
121
|
+
return self._ttl
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Write
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def set(self, value, ttl=None):
|
|
128
|
+
"""
|
|
129
|
+
Set the cached value.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
value: The value to cache.
|
|
133
|
+
ttl: Optional TTL override for this specific write.
|
|
134
|
+
Does not change the cache's default TTL.
|
|
135
|
+
"""
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._value = value
|
|
138
|
+
self._updated_at = time.time()
|
|
139
|
+
self._entry_ttl = self._ttl if ttl is None else max(0.0, float(ttl))
|
|
140
|
+
|
|
141
|
+
def get_or_compute(self, factory, ttl=None):
|
|
142
|
+
"""
|
|
143
|
+
Return cached value if valid, otherwise compute it using
|
|
144
|
+
``factory()`` and cache the result.
|
|
145
|
+
|
|
146
|
+
This is atomic — only one greenlet runs the factory at a time.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
factory: Callable that returns the value to cache.
|
|
150
|
+
ttl: Optional TTL override.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The cached or freshly computed value.
|
|
154
|
+
"""
|
|
155
|
+
# Fast path: check without lock contention
|
|
156
|
+
value = self.get()
|
|
157
|
+
if value is not None:
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
# Slow path: compute under lock
|
|
161
|
+
with self._lock:
|
|
162
|
+
# Double-check after acquiring lock
|
|
163
|
+
if self._value is not None:
|
|
164
|
+
if (
|
|
165
|
+
self._entry_ttl == 0 or
|
|
166
|
+
(time.time() - self._updated_at) < self._entry_ttl
|
|
167
|
+
):
|
|
168
|
+
return self._value
|
|
169
|
+
|
|
170
|
+
computed = factory()
|
|
171
|
+
self._value = computed
|
|
172
|
+
self._updated_at = time.time()
|
|
173
|
+
self._entry_ttl = self._ttl if ttl is None else max(0.0, float(ttl))
|
|
174
|
+
return computed
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Invalidation
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def invalidate(self):
|
|
181
|
+
"""
|
|
182
|
+
Clear the cache and emit ``'{name}:invalidated'`` on the bus.
|
|
183
|
+
|
|
184
|
+
Any caches that ``depend_on`` this event will cascade-invalidate.
|
|
185
|
+
Also fires registered ``on_invalidate`` callbacks.
|
|
186
|
+
"""
|
|
187
|
+
with self._lock:
|
|
188
|
+
was_valid = self._value is not None
|
|
189
|
+
self._value = None
|
|
190
|
+
self._updated_at = 0
|
|
191
|
+
self._entry_ttl = self._ttl
|
|
192
|
+
|
|
193
|
+
if was_valid:
|
|
194
|
+
logger.info(f"[SPECTER:cache] '{self.name}' invalidated")
|
|
195
|
+
|
|
196
|
+
# Fire invalidation callbacks
|
|
197
|
+
for cb in list(self._invalidate_callbacks):
|
|
198
|
+
try:
|
|
199
|
+
cb()
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.error(
|
|
202
|
+
f"[SPECTER:cache] Error in invalidation callback "
|
|
203
|
+
f"for '{self.name}': {e}",
|
|
204
|
+
exc_info=True,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Cascade: emit so dependent caches can react
|
|
208
|
+
bus.emit(f'{self.name}:invalidated')
|
|
209
|
+
|
|
210
|
+
def on_invalidate(self, callback):
|
|
211
|
+
"""
|
|
212
|
+
Subscribe to invalidation events for this cache.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
callback: Callable (no arguments) invoked when the cache
|
|
216
|
+
is invalidated.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
An unsubscribe function.
|
|
220
|
+
"""
|
|
221
|
+
self._invalidate_callbacks.append(callback)
|
|
222
|
+
return lambda: (
|
|
223
|
+
self._invalidate_callbacks.remove(callback)
|
|
224
|
+
if callback in self._invalidate_callbacks else None
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Lifecycle
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def destroy(self):
|
|
232
|
+
"""
|
|
233
|
+
Tear down the cache: unsubscribe from all bus events,
|
|
234
|
+
clear callbacks, and reset state. Called by the framework
|
|
235
|
+
when the owning service stops.
|
|
236
|
+
"""
|
|
237
|
+
for unsub in self._bus_unsubs:
|
|
238
|
+
try:
|
|
239
|
+
unsub()
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
self._bus_unsubs.clear()
|
|
243
|
+
self._invalidate_callbacks.clear()
|
|
244
|
+
with self._lock:
|
|
245
|
+
self._value = None
|
|
246
|
+
self._updated_at = 0
|
|
247
|
+
self._entry_ttl = self._ttl
|
|
248
|
+
logger.debug(f"[SPECTER:cache] '{self.name}' destroyed")
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Internal
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def _on_dependency_invalidated(self, data=None):
|
|
255
|
+
"""Bus callback: a dependency was invalidated, so we invalidate too."""
|
|
256
|
+
logger.debug(
|
|
257
|
+
f"[SPECTER:cache] '{self.name}' dependency triggered, invalidating"
|
|
258
|
+
)
|
|
259
|
+
self.invalidate()
|
|
260
|
+
|
|
261
|
+
def __repr__(self):
|
|
262
|
+
valid = self.is_valid()
|
|
263
|
+
return (
|
|
264
|
+
f"<Cache '{self.name}' valid={valid} "
|
|
265
|
+
f"ttl={self._ttl}s>"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def create_cache(name, ttl=0, depends_on=None):
|
|
270
|
+
"""
|
|
271
|
+
Factory for creating a named cache.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Unique cache name.
|
|
275
|
+
ttl: Time-to-live in seconds. ``0`` = no auto-expiry.
|
|
276
|
+
depends_on: List of bus event names that trigger invalidation.
|
|
277
|
+
Convention: ``'{cache_name}:invalidated'``
|
|
278
|
+
or domain events like ``'storage:mounts'``.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
A :class:`Cache` instance.
|
|
282
|
+
|
|
283
|
+
Example::
|
|
284
|
+
|
|
285
|
+
category_cache = create_cache(
|
|
286
|
+
'categories',
|
|
287
|
+
ttl=86400,
|
|
288
|
+
depends_on=['storage:mounts'],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Set
|
|
292
|
+
category_cache.set(categories_list)
|
|
293
|
+
|
|
294
|
+
# Get (returns None if expired)
|
|
295
|
+
cached = category_cache.get()
|
|
296
|
+
|
|
297
|
+
# Auto-invalidates when 'storage:mounts' fires on the bus
|
|
298
|
+
"""
|
|
299
|
+
if depends_on is None:
|
|
300
|
+
depends_on = []
|
|
301
|
+
return Cache(name=name, ttl=ttl, depends_on=depends_on)
|