mycorrhizal 0.2.0__py3-none-any.whl → 0.2.2__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.
- mycorrhizal/_version.py +1 -1
- mycorrhizal/hypha/core/runtime.py +42 -30
- mycorrhizal/rhizomorph/core.py +329 -11
- mycorrhizal/spores/__init__.py +8 -0
- mycorrhizal/spores/cache.py +92 -30
- mycorrhizal/spores/core.py +208 -53
- mycorrhizal/spores/encoder/json.py +17 -28
- {mycorrhizal-0.2.0.dist-info → mycorrhizal-0.2.2.dist-info}/METADATA +1 -1
- {mycorrhizal-0.2.0.dist-info → mycorrhizal-0.2.2.dist-info}/RECORD +10 -10
- {mycorrhizal-0.2.0.dist-info → mycorrhizal-0.2.2.dist-info}/WHEEL +0 -0
mycorrhizal/spores/cache.py
CHANGED
|
@@ -8,11 +8,14 @@ LRU cache for tracking seen objects and logging them on eviction.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
from collections import OrderedDict
|
|
11
|
-
from typing import Dict, Callable, Optional, TypeVar, Generic
|
|
11
|
+
from typing import Dict, Callable, Optional, TypeVar, Generic, TYPE_CHECKING
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
|
|
14
14
|
from .models import Object
|
|
15
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .core import CacheMetrics
|
|
18
|
+
|
|
16
19
|
|
|
17
20
|
# Generic types for cache
|
|
18
21
|
K = TypeVar('K') # Key type (object ID)
|
|
@@ -28,61 +31,90 @@ class CacheEntry:
|
|
|
28
31
|
object: The OCEL Object
|
|
29
32
|
sight_count: How many times we've seen this object
|
|
30
33
|
first_sight_time: When we first saw the object
|
|
34
|
+
attributes_hash: Hash of object attributes for change detection
|
|
31
35
|
"""
|
|
32
36
|
object: Object
|
|
33
37
|
sight_count: int = 1
|
|
34
38
|
first_sight_time: Optional[float] = None
|
|
39
|
+
attributes_hash: Optional[int] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _compute_attributes_hash(obj: Object) -> Optional[int]:
|
|
43
|
+
"""
|
|
44
|
+
Compute hash of object attributes for change detection.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
obj: The OCEL Object
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Hash of attributes, or None if object has no attributes
|
|
51
|
+
"""
|
|
52
|
+
if not obj.attributes:
|
|
53
|
+
return None
|
|
54
|
+
# Hash frozenset of (attr_name, attr_value) tuples
|
|
55
|
+
# ObjectAttributeValue.value holds the actual Python value
|
|
56
|
+
attrs = {k: v.value for k, v in obj.attributes.items()}
|
|
57
|
+
return hash(frozenset(attrs.items()))
|
|
35
58
|
|
|
36
59
|
|
|
37
60
|
class ObjectLRUCache(Generic[K, V]):
|
|
38
61
|
"""
|
|
39
|
-
LRU cache with
|
|
62
|
+
LRU cache with unified callback for object logging.
|
|
40
63
|
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
The cache fires the `needs_logged` callback when an object needs to be logged:
|
|
65
|
+
- On first sight (new object added to cache)
|
|
66
|
+
- On eviction (object removed from cache to make room)
|
|
67
|
+
- When attributes change (detected via hash comparison)
|
|
68
|
+
- Every N touches (configurable via `touch_resend_n`)
|
|
43
69
|
|
|
44
70
|
This ensures OCEL consumers see object evolution:
|
|
45
71
|
- First sight: Initial object state
|
|
72
|
+
- Attribute changes: Updated state
|
|
46
73
|
- Eviction: Final state before being removed from cache
|
|
74
|
+
- Periodic resend: Long-lived objects are re-logged periodically
|
|
47
75
|
|
|
48
76
|
Example:
|
|
49
77
|
```python
|
|
50
|
-
def
|
|
78
|
+
def needs_logged(object_id: str, obj: Object):
|
|
51
79
|
# Send object to transport
|
|
52
80
|
transport.send(LogRecord(object=obj))
|
|
53
81
|
|
|
54
|
-
cache = ObjectLRUCache(maxsize=128,
|
|
82
|
+
cache = ObjectLRUCache(maxsize=128, needs_logged=needs_logged, touch_resend_n=100)
|
|
55
83
|
|
|
56
84
|
# Check if object exists, add if not
|
|
57
85
|
if not cache.contains_or_add(object_id, object):
|
|
58
|
-
# Object not in cache, already logged by
|
|
86
|
+
# Object not in cache, already logged by needs_logged
|
|
59
87
|
pass
|
|
60
88
|
```
|
|
61
89
|
|
|
62
90
|
Args:
|
|
63
91
|
maxsize: Maximum number of objects to cache
|
|
64
|
-
|
|
65
|
-
|
|
92
|
+
needs_logged: Callback when object needs logging (object_id, object) -> None
|
|
93
|
+
touch_resend_n: Resend object every N touches (0 to disable periodic resend)
|
|
66
94
|
"""
|
|
67
95
|
|
|
68
96
|
def __init__(
|
|
69
97
|
self,
|
|
70
98
|
maxsize: int = 128,
|
|
71
|
-
|
|
72
|
-
|
|
99
|
+
needs_logged: Optional[Callable[[K, Object], None]] = None,
|
|
100
|
+
touch_resend_n: int = 100,
|
|
101
|
+
metrics: Optional["CacheMetrics"] = None
|
|
73
102
|
):
|
|
74
103
|
self.maxsize = maxsize
|
|
75
|
-
self.
|
|
76
|
-
self.
|
|
104
|
+
self.needs_logged = needs_logged
|
|
105
|
+
self.touch_resend_n = touch_resend_n
|
|
106
|
+
self.metrics = metrics
|
|
77
107
|
self._cache: OrderedDict[K, CacheEntry] = OrderedDict()
|
|
78
108
|
|
|
79
109
|
def _evict_if_needed(self) -> None:
|
|
80
110
|
"""Evict oldest entry if cache is full."""
|
|
81
|
-
|
|
111
|
+
while len(self._cache) >= self.maxsize:
|
|
82
112
|
# FIFO from OrderedDict (oldest first)
|
|
83
113
|
object_id, entry = self._cache.popitem(last=False)
|
|
84
|
-
if self.
|
|
85
|
-
self.
|
|
114
|
+
if self.metrics is not None:
|
|
115
|
+
self.metrics.evictions += 1
|
|
116
|
+
if self.needs_logged:
|
|
117
|
+
self.needs_logged(object_id, entry.object)
|
|
86
118
|
|
|
87
119
|
def get(self, key: K) -> Optional[Object]:
|
|
88
120
|
"""
|
|
@@ -119,8 +151,13 @@ class ObjectLRUCache(Generic[K, V]):
|
|
|
119
151
|
Check if key exists, add if not.
|
|
120
152
|
|
|
121
153
|
This is the primary method for object tracking:
|
|
122
|
-
- If key exists: mark as recently used
|
|
123
|
-
- If key doesn't exist: add object, potentially evict,
|
|
154
|
+
- If key exists: check for attribute changes, periodic resend, mark as recently used
|
|
155
|
+
- If key doesn't exist: add object, potentially evict, log first sight
|
|
156
|
+
|
|
157
|
+
The `needs_logged` callback is fired when:
|
|
158
|
+
- First sight: new object added to cache
|
|
159
|
+
- Attribute change: object attributes changed (hash comparison)
|
|
160
|
+
- Every N touches: periodic resend for long-lived objects
|
|
124
161
|
|
|
125
162
|
Args:
|
|
126
163
|
key: The object ID
|
|
@@ -130,27 +167,51 @@ class ObjectLRUCache(Generic[K, V]):
|
|
|
130
167
|
True if object was already in cache, False if it was just added
|
|
131
168
|
"""
|
|
132
169
|
if key in self._cache:
|
|
133
|
-
#
|
|
134
|
-
self._cache.move_to_end(key)
|
|
170
|
+
# Object exists - check if we need to log
|
|
135
171
|
entry = self._cache[key]
|
|
136
172
|
entry.sight_count += 1
|
|
173
|
+
|
|
174
|
+
# Compute current attribute hash
|
|
175
|
+
current_hash = _compute_attributes_hash(obj)
|
|
176
|
+
|
|
177
|
+
# Check if attributes changed
|
|
178
|
+
attrs_changed = (entry.attributes_hash is not None and
|
|
179
|
+
current_hash != entry.attributes_hash)
|
|
180
|
+
|
|
181
|
+
# Update if attributes changed
|
|
182
|
+
if attrs_changed:
|
|
183
|
+
entry.object = obj
|
|
184
|
+
entry.attributes_hash = current_hash
|
|
185
|
+
|
|
186
|
+
# Fire callback if ANY condition met
|
|
187
|
+
should_log = any([
|
|
188
|
+
attrs_changed,
|
|
189
|
+
self.touch_resend_n > 0 and entry.sight_count % self.touch_resend_n == 0,
|
|
190
|
+
])
|
|
191
|
+
if should_log and self.needs_logged:
|
|
192
|
+
self.needs_logged(key, entry.object)
|
|
193
|
+
|
|
194
|
+
self._cache.move_to_end(key)
|
|
137
195
|
return True
|
|
138
196
|
else:
|
|
139
|
-
# First sight
|
|
197
|
+
# First sight - store hash, fire callback
|
|
140
198
|
self._evict_if_needed()
|
|
141
|
-
|
|
142
|
-
|
|
199
|
+
if self.metrics is not None:
|
|
200
|
+
self.metrics.first_sights += 1
|
|
201
|
+
attr_hash = _compute_attributes_hash(obj)
|
|
202
|
+
entry = CacheEntry(object=obj, attributes_hash=attr_hash)
|
|
143
203
|
self._cache[key] = entry
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
self.on_first_sight(key, obj)
|
|
147
|
-
|
|
204
|
+
if self.needs_logged:
|
|
205
|
+
self.needs_logged(key, obj)
|
|
148
206
|
return False
|
|
149
207
|
|
|
150
208
|
def add(self, key: K, obj: Object) -> None:
|
|
151
209
|
"""
|
|
152
210
|
Add an object to the cache (or update if exists).
|
|
153
211
|
|
|
212
|
+
Note: This method fires needs_logged for new objects, but not for updates.
|
|
213
|
+
For attribute change detection and periodic resend, use contains_or_add().
|
|
214
|
+
|
|
154
215
|
Args:
|
|
155
216
|
key: The object ID
|
|
156
217
|
obj: The OCEL Object
|
|
@@ -165,11 +226,12 @@ class ObjectLRUCache(Generic[K, V]):
|
|
|
165
226
|
# New entry
|
|
166
227
|
self._evict_if_needed()
|
|
167
228
|
|
|
168
|
-
|
|
229
|
+
attr_hash = _compute_attributes_hash(obj)
|
|
230
|
+
entry = CacheEntry(object=obj, attributes_hash=attr_hash)
|
|
169
231
|
self._cache[key] = entry
|
|
170
232
|
|
|
171
|
-
if self.
|
|
172
|
-
self.
|
|
233
|
+
if self.needs_logged:
|
|
234
|
+
self.needs_logged(key, obj)
|
|
173
235
|
|
|
174
236
|
def remove(self, key: K) -> Optional[Object]:
|
|
175
237
|
"""
|
mycorrhizal/spores/core.py
CHANGED
|
@@ -13,11 +13,12 @@ import functools
|
|
|
13
13
|
import logging
|
|
14
14
|
import threading
|
|
15
15
|
from datetime import datetime
|
|
16
|
+
from enum import Enum
|
|
16
17
|
from typing import (
|
|
17
18
|
Any, Callable, Optional, Union, Dict, List,
|
|
18
19
|
ParamSpec, TypeVar, overload, get_origin, get_args, Annotated
|
|
19
20
|
)
|
|
20
|
-
from dataclasses import dataclass
|
|
21
|
+
from dataclasses import dataclass, field
|
|
21
22
|
from abc import ABC, abstractmethod
|
|
22
23
|
|
|
23
24
|
from .models import (
|
|
@@ -44,6 +45,28 @@ R = TypeVar('R')
|
|
|
44
45
|
# Global Configuration
|
|
45
46
|
# ============================================================================
|
|
46
47
|
|
|
48
|
+
class EvictionPolicy(str, Enum):
|
|
49
|
+
"""
|
|
50
|
+
Cache eviction policy for object logging.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
EVICT_AND_LOG: Evict from cache when full, log immediately via sync/async path
|
|
54
|
+
EVICT_AND_BUFFER: Evict from cache, buffer for later logging (future)
|
|
55
|
+
NO_EVICT: Keep in cache until explicit flush (future)
|
|
56
|
+
"""
|
|
57
|
+
EVICT_AND_LOG = "evict_and_log"
|
|
58
|
+
EVICT_AND_BUFFER = "evict_and_buffer"
|
|
59
|
+
NO_EVICT = "no_evict"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class CacheMetrics:
|
|
64
|
+
"""Track cache eviction statistics."""
|
|
65
|
+
evictions: int = 0
|
|
66
|
+
eviction_failures: int = 0
|
|
67
|
+
first_sights: int = 0
|
|
68
|
+
|
|
69
|
+
|
|
47
70
|
@dataclass
|
|
48
71
|
class SporesConfig:
|
|
49
72
|
"""
|
|
@@ -54,11 +77,15 @@ class SporesConfig:
|
|
|
54
77
|
object_cache_size: Maximum objects in LRU cache
|
|
55
78
|
encoder: Encoder instance to use
|
|
56
79
|
transport: Transport instance to use (required for logging to work)
|
|
80
|
+
eviction_policy: Policy for handling cache eviction
|
|
81
|
+
touch_resend_n: Resend object every N touches (0 to disable periodic resend)
|
|
57
82
|
"""
|
|
58
83
|
enabled: bool = True
|
|
59
84
|
object_cache_size: int = 128
|
|
60
85
|
encoder: Optional[Encoder] = None
|
|
61
86
|
transport: Optional[Transport] = None
|
|
87
|
+
eviction_policy: EvictionPolicy = EvictionPolicy.EVICT_AND_LOG
|
|
88
|
+
touch_resend_n: int = 100
|
|
62
89
|
|
|
63
90
|
def __post_init__(self):
|
|
64
91
|
"""Set default encoder if not provided."""
|
|
@@ -69,24 +96,34 @@ class SporesConfig:
|
|
|
69
96
|
# Global state
|
|
70
97
|
_config: Optional[SporesConfig] = None
|
|
71
98
|
_object_cache: Optional[ObjectLRUCache[str, Object]] = None
|
|
99
|
+
_config_lock = threading.RLock() # Reentrant lock for global state initialization
|
|
100
|
+
_config_initialized = False # Track whether configure() has been called
|
|
101
|
+
_cache_metrics = CacheMetrics() # Track cache eviction statistics
|
|
72
102
|
|
|
73
103
|
|
|
74
104
|
def configure(
|
|
75
105
|
enabled: bool = True,
|
|
76
106
|
object_cache_size: int = 128,
|
|
77
107
|
encoder: Optional[Encoder] = None,
|
|
78
|
-
transport: Optional[Transport] = None
|
|
108
|
+
transport: Optional[Transport] = None,
|
|
109
|
+
eviction_policy: Union[EvictionPolicy, str] = EvictionPolicy.EVICT_AND_LOG,
|
|
110
|
+
touch_resend_n: int = 100
|
|
79
111
|
) -> None:
|
|
80
112
|
"""
|
|
81
|
-
Configure the Spores logging system.
|
|
113
|
+
Configure the Spores logging system (thread-safe).
|
|
82
114
|
|
|
83
115
|
This should be called once at application startup.
|
|
116
|
+
If called multiple times, the last call wins.
|
|
117
|
+
|
|
118
|
+
Thread-safe: Can be called from multiple threads concurrently.
|
|
84
119
|
|
|
85
120
|
Args:
|
|
86
121
|
enabled: Whether spores logging is enabled (default: True)
|
|
87
122
|
object_cache_size: Maximum objects in LRU cache (default: 128)
|
|
88
123
|
encoder: Encoder instance (defaults to JSONEncoder)
|
|
89
124
|
transport: Transport instance (required for logging to work)
|
|
125
|
+
eviction_policy: Policy for cache eviction (default: evict_and_log)
|
|
126
|
+
touch_resend_n: Resend object every N touches (default: 100, 0 to disable)
|
|
90
127
|
|
|
91
128
|
Example:
|
|
92
129
|
```python
|
|
@@ -94,72 +131,183 @@ def configure(
|
|
|
94
131
|
|
|
95
132
|
spore.configure(
|
|
96
133
|
transport=FileTransport("logs/ocel.jsonl"),
|
|
97
|
-
object_cache_size=256
|
|
134
|
+
object_cache_size=256,
|
|
135
|
+
touch_resend_n=100,
|
|
98
136
|
)
|
|
99
137
|
```
|
|
100
138
|
"""
|
|
101
|
-
global _config, _object_cache
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def on_first_sight(object_id: str, obj: Object):
|
|
125
|
-
"""Send object when first seen."""
|
|
126
|
-
try:
|
|
127
|
-
loop = asyncio.get_event_loop()
|
|
128
|
-
if loop.is_running():
|
|
129
|
-
asyncio.create_task(_send_log_record(LogRecord(object=obj)))
|
|
130
|
-
else:
|
|
131
|
-
# No running loop, schedule the coroutine
|
|
132
|
-
asyncio.ensure_future(_send_log_record(LogRecord(object=obj)))
|
|
133
|
-
except RuntimeError:
|
|
134
|
-
# No event loop at all, ignore for now
|
|
135
|
-
pass
|
|
139
|
+
global _config, _object_cache, _config_initialized, _cache_metrics
|
|
140
|
+
|
|
141
|
+
# Convert string to EvictionPolicy if needed
|
|
142
|
+
if isinstance(eviction_policy, str):
|
|
143
|
+
eviction_policy = EvictionPolicy(eviction_policy)
|
|
144
|
+
|
|
145
|
+
with _config_lock:
|
|
146
|
+
# Warn if already configured (but allow reconfiguration)
|
|
147
|
+
if _config_initialized:
|
|
148
|
+
logger.warning(
|
|
149
|
+
"Spores already configured. Reconfiguring may cause issues. "
|
|
150
|
+
"Ensure configure() is called only once at startup."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Create new config (atomic under lock)
|
|
154
|
+
_config = SporesConfig(
|
|
155
|
+
enabled=enabled,
|
|
156
|
+
object_cache_size=object_cache_size,
|
|
157
|
+
encoder=encoder,
|
|
158
|
+
transport=transport,
|
|
159
|
+
eviction_policy=eviction_policy,
|
|
160
|
+
touch_resend_n=touch_resend_n
|
|
161
|
+
)
|
|
136
162
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
on_evict=on_evict,
|
|
140
|
-
on_first_sight=on_first_sight
|
|
141
|
-
)
|
|
163
|
+
# Reset metrics on reconfiguration
|
|
164
|
+
_cache_metrics = CacheMetrics()
|
|
142
165
|
|
|
143
|
-
|
|
166
|
+
# Create object cache with unified callback
|
|
167
|
+
def needs_logged(object_id: str, obj: Object):
|
|
168
|
+
"""Log object when it needs to be logged (first sight, eviction, change, or Nth touch)."""
|
|
169
|
+
global _cache_metrics
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
loop = asyncio.get_event_loop()
|
|
173
|
+
if loop.is_running():
|
|
174
|
+
# Async context: schedule task
|
|
175
|
+
asyncio.create_task(_send_log_record(LogRecord(object=obj)))
|
|
176
|
+
else:
|
|
177
|
+
# No running loop, use sync path
|
|
178
|
+
_send_log_record_sync(LogRecord(object=obj))
|
|
179
|
+
except RuntimeError:
|
|
180
|
+
# No event loop at all, use sync path
|
|
181
|
+
try:
|
|
182
|
+
_send_log_record_sync(LogRecord(object=obj))
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Failed to log object {object_id}: {e}")
|
|
185
|
+
|
|
186
|
+
_object_cache = ObjectLRUCache(
|
|
187
|
+
maxsize=object_cache_size,
|
|
188
|
+
needs_logged=needs_logged,
|
|
189
|
+
touch_resend_n=touch_resend_n,
|
|
190
|
+
metrics=_cache_metrics
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
_config_initialized = True
|
|
194
|
+
|
|
195
|
+
logger.info(f"Spores configured: enabled={enabled}, cache_size={object_cache_size}, eviction_policy={eviction_policy.value}")
|
|
144
196
|
|
|
145
197
|
|
|
146
198
|
def get_config() -> SporesConfig:
|
|
147
|
-
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
199
|
+
"""
|
|
200
|
+
Get the current spores configuration (thread-safe).
|
|
201
|
+
|
|
202
|
+
If no configuration exists, creates a default one.
|
|
203
|
+
Thread-safe: Can be called from multiple threads concurrently.
|
|
204
|
+
"""
|
|
205
|
+
global _config, _config_initialized
|
|
206
|
+
|
|
207
|
+
# Fast path: read without lock (GIL protects single read)
|
|
208
|
+
if _config is not None:
|
|
209
|
+
return _config
|
|
210
|
+
|
|
211
|
+
# Slow path: needs initialization
|
|
212
|
+
with _config_lock:
|
|
213
|
+
# Double-check: another thread may have initialized while we waited
|
|
214
|
+
if _config is None:
|
|
215
|
+
# Initialize with defaults
|
|
216
|
+
_config = SporesConfig()
|
|
217
|
+
_config_initialized = True
|
|
218
|
+
|
|
152
219
|
return _config
|
|
153
220
|
|
|
154
221
|
|
|
155
222
|
def get_object_cache() -> ObjectLRUCache[str, Object]:
|
|
156
|
-
"""
|
|
223
|
+
"""
|
|
224
|
+
Get the object cache (thread-safe).
|
|
225
|
+
|
|
226
|
+
If no cache exists, initializes with default configuration.
|
|
227
|
+
Thread-safe: Can be called from multiple threads concurrently.
|
|
228
|
+
"""
|
|
157
229
|
global _object_cache
|
|
158
|
-
|
|
159
|
-
|
|
230
|
+
|
|
231
|
+
# Fast path: read without lock (GIL protects single read)
|
|
232
|
+
if _object_cache is not None:
|
|
233
|
+
return _object_cache
|
|
234
|
+
|
|
235
|
+
# Slow path: needs initialization
|
|
236
|
+
with _config_lock:
|
|
237
|
+
# Double-check: another thread may have initialized while we waited
|
|
238
|
+
if _object_cache is None:
|
|
239
|
+
# Trigger full initialization
|
|
240
|
+
configure() # Will acquire lock again, but that's OK (reentrant in same thread)
|
|
241
|
+
|
|
160
242
|
return _object_cache
|
|
161
243
|
|
|
162
244
|
|
|
245
|
+
def flush_object_cache() -> None:
|
|
246
|
+
"""
|
|
247
|
+
Flush all objects from the cache to the log.
|
|
248
|
+
|
|
249
|
+
This forces all cached objects to be written, even if they haven't
|
|
250
|
+
been evicted yet. Use this before application shutdown to ensure
|
|
251
|
+
all objects are logged.
|
|
252
|
+
|
|
253
|
+
Thread-safe: Can be called from multiple threads concurrently.
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
```python
|
|
257
|
+
from mycorrhizal.spores import configure, flush_object_cache
|
|
258
|
+
from mycorrhizal.spores.transport import FileTransport
|
|
259
|
+
|
|
260
|
+
configure(transport=FileTransport("logs/ocel.jsonl"))
|
|
261
|
+
|
|
262
|
+
# ... application logic ...
|
|
263
|
+
|
|
264
|
+
flush_object_cache() # Ensure all objects logged
|
|
265
|
+
```
|
|
266
|
+
"""
|
|
267
|
+
cache = get_object_cache()
|
|
268
|
+
config = get_config()
|
|
269
|
+
|
|
270
|
+
if not config.enabled:
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# Get all objects currently in cache
|
|
274
|
+
all_objects = list(cache._cache.values())
|
|
275
|
+
|
|
276
|
+
# Log each object
|
|
277
|
+
flushed_count = 0
|
|
278
|
+
for entry in all_objects:
|
|
279
|
+
try:
|
|
280
|
+
# Use sync path to ensure it's written
|
|
281
|
+
_send_log_record_sync(LogRecord(object=entry.object))
|
|
282
|
+
flushed_count += 1
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"Failed to flush object {entry.object.id}: {e}")
|
|
285
|
+
|
|
286
|
+
logger.info(f"Flushed {flushed_count} objects from cache")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_cache_metrics() -> CacheMetrics:
|
|
290
|
+
"""
|
|
291
|
+
Get cache eviction metrics.
|
|
292
|
+
|
|
293
|
+
Returns statistics about cache evictions and failures.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
CacheMetrics with eviction statistics
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
```python
|
|
300
|
+
from mycorrhizal.spores import get_cache_metrics
|
|
301
|
+
|
|
302
|
+
metrics = get_cache_metrics()
|
|
303
|
+
print(f"Evictions: {metrics.evictions}")
|
|
304
|
+
print(f"Failures: {metrics.eviction_failures}")
|
|
305
|
+
```
|
|
306
|
+
"""
|
|
307
|
+
global _cache_metrics
|
|
308
|
+
return _cache_metrics
|
|
309
|
+
|
|
310
|
+
|
|
163
311
|
async def _send_log_record(record: LogRecord) -> None:
|
|
164
312
|
"""
|
|
165
313
|
Send a log record via the configured transport (async).
|
|
@@ -1236,6 +1384,13 @@ __all__ = [
|
|
|
1236
1384
|
'configure',
|
|
1237
1385
|
'get_config',
|
|
1238
1386
|
'get_object_cache',
|
|
1387
|
+
'flush_object_cache',
|
|
1388
|
+
'get_cache_metrics',
|
|
1389
|
+
|
|
1390
|
+
# Types
|
|
1391
|
+
'SporesConfig',
|
|
1392
|
+
'EvictionPolicy',
|
|
1393
|
+
'CacheMetrics',
|
|
1239
1394
|
|
|
1240
1395
|
# Spore getters (explicit)
|
|
1241
1396
|
'get_spore_sync',
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
from datetime import datetime
|
|
13
|
-
from typing import Any, Dict
|
|
13
|
+
from typing import Any, Dict, TYPE_CHECKING
|
|
14
14
|
|
|
15
15
|
from .base import Encoder
|
|
16
16
|
from ..models import LogRecord, Event, Object, Relationship, EventAttributeValue, ObjectAttributeValue
|
|
@@ -20,7 +20,7 @@ class JSONEncoder(Encoder):
|
|
|
20
20
|
"""
|
|
21
21
|
JSON encoder for OCEL LogRecords.
|
|
22
22
|
|
|
23
|
-
Produces OCEL-compatible JSON.
|
|
23
|
+
Produces OCEL-compatible JSON with Unix float64 timestamps.
|
|
24
24
|
|
|
25
25
|
Example output:
|
|
26
26
|
{
|
|
@@ -28,9 +28,9 @@ class JSONEncoder(Encoder):
|
|
|
28
28
|
"id": "evt-123",
|
|
29
29
|
"type": "process_item",
|
|
30
30
|
"activity": null,
|
|
31
|
-
"time":
|
|
31
|
+
"time": 1705689296.123456,
|
|
32
32
|
"attributes": [
|
|
33
|
-
{"name": "priority", "value": "high", "type": "string"
|
|
33
|
+
{"name": "priority", "value": "high", "type": "string"}
|
|
34
34
|
],
|
|
35
35
|
"relationships": [
|
|
36
36
|
{"objectId": "obj-456", "qualifier": "input"}
|
|
@@ -46,13 +46,21 @@ class JSONEncoder(Encoder):
|
|
|
46
46
|
"id": "obj-456",
|
|
47
47
|
"type": "WorkItem",
|
|
48
48
|
"attributes": [
|
|
49
|
-
{"name": "status", "value": "pending", "type": "string", "time":
|
|
49
|
+
{"name": "status", "value": "pending", "type": "string", "time": 1705689296.123456}
|
|
50
50
|
],
|
|
51
51
|
"relationships": []
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
"""
|
|
55
55
|
|
|
56
|
+
def __init__(self):
|
|
57
|
+
"""
|
|
58
|
+
Initialize JSONEncoder.
|
|
59
|
+
|
|
60
|
+
Timestamps are always encoded as Unix float64 timestamps.
|
|
61
|
+
"""
|
|
62
|
+
pass
|
|
63
|
+
|
|
56
64
|
def encode(self, record: LogRecord) -> bytes:
|
|
57
65
|
"""
|
|
58
66
|
Encode a LogRecord to JSON bytes.
|
|
@@ -136,33 +144,14 @@ class JSONEncoder(Encoder):
|
|
|
136
144
|
"qualifier": qualifier
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
def _format_datetime(self, dt: datetime) ->
|
|
147
|
+
def _format_datetime(self, dt: datetime) -> float:
|
|
140
148
|
"""
|
|
141
|
-
Format datetime
|
|
149
|
+
Format datetime as Unix float64 timestamp.
|
|
142
150
|
|
|
143
151
|
Args:
|
|
144
152
|
dt: The datetime to format
|
|
145
153
|
|
|
146
154
|
Returns:
|
|
147
|
-
|
|
155
|
+
Unix float64 timestamp (seconds since epoch)
|
|
148
156
|
"""
|
|
149
|
-
|
|
150
|
-
# For nanosecond precision, we need to ensure it has 9 digits
|
|
151
|
-
iso = dt.isoformat()
|
|
152
|
-
|
|
153
|
-
# Add microseconds if not present (Python 3.11+ has timespec='nanoseconds')
|
|
154
|
-
if '.' not in iso:
|
|
155
|
-
iso += '.000000000'
|
|
156
|
-
|
|
157
|
-
# Ensure we have 9 digits of fractional seconds
|
|
158
|
-
if '.' in iso:
|
|
159
|
-
main, frac = iso.split('.')
|
|
160
|
-
# Pad or truncate to 9 digits
|
|
161
|
-
frac = (frac + '0' * 9)[:9]
|
|
162
|
-
iso = f"{main}.{frac}"
|
|
163
|
-
|
|
164
|
-
# Ensure timezone is Z (UTC) or offset
|
|
165
|
-
if iso.endswith('+00:00'):
|
|
166
|
-
iso = iso[:-6] + 'Z'
|
|
167
|
-
|
|
168
|
-
return iso
|
|
157
|
+
return dt.timestamp()
|