fleetmesh-strfry 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.
- fleetmesh_strfry/__init__.py +36 -0
- fleetmesh_strfry/__main__.py +122 -0
- fleetmesh_strfry/bridge.py +236 -0
- fleetmesh_strfry/pipe.py +78 -0
- fleetmesh_strfry/plugin.py +273 -0
- fleetmesh_strfry/policy.py +436 -0
- fleetmesh_strfry-0.1.0.dist-info/METADATA +145 -0
- fleetmesh_strfry-0.1.0.dist-info/RECORD +12 -0
- fleetmesh_strfry-0.1.0.dist-info/WHEEL +5 -0
- fleetmesh_strfry-0.1.0.dist-info/entry_points.txt +2 -0
- fleetmesh_strfry-0.1.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_strfry-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""fleetmesh-strfry: the strfry relay bridge for a Fleetmesh node.
|
|
2
|
+
|
|
3
|
+
Two ways to run it, both over the same classes:
|
|
4
|
+
|
|
5
|
+
- in the node's process: `fleetmesh_strfry.plugin:StrfryBridgePlugin` in the
|
|
6
|
+
node's `plugins` config block;
|
|
7
|
+
- beside the node: `python -m fleetmesh_strfry run --dir NODE_DIR`.
|
|
8
|
+
|
|
9
|
+
This package imports `node.protocols.nostr` (`client`, `eventstore`),
|
|
10
|
+
`node.storage.grants`, `node.keystore`, `node.interfaces`, `node.compartment`
|
|
11
|
+
and `fleetmesh_core` at runtime: it runs on the node host with the meshnode
|
|
12
|
+
and core checkouts on PYTHONPATH. It has no other dependency beyond the standard library.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .bridge import StrfryRelayBridge
|
|
17
|
+
from .pipe import StrfryPipe
|
|
18
|
+
from .plugin import PLUGIN_REF, StrfryBridgePlugin, cmd_strfry, register_cli
|
|
19
|
+
from .policy import (
|
|
20
|
+
DEFAULT_STRFRY_URL,
|
|
21
|
+
MESH_CONTROL_KINDS,
|
|
22
|
+
RateLimiter,
|
|
23
|
+
StrfryPluginRunner,
|
|
24
|
+
StrfryPolicyConfig,
|
|
25
|
+
StrfryPolicyEngine,
|
|
26
|
+
StrfryPolicyError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = '0.1.0'
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
'DEFAULT_STRFRY_URL', 'MESH_CONTROL_KINDS', 'PLUGIN_REF', 'RateLimiter',
|
|
33
|
+
'StrfryBridgePlugin', 'StrfryPipe', 'StrfryPluginRunner', 'StrfryPolicyConfig',
|
|
34
|
+
'StrfryPolicyEngine', 'StrfryPolicyError', 'StrfryRelayBridge',
|
|
35
|
+
'cmd_strfry', 'register_cli', '__version__',
|
|
36
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`python -m fleetmesh_strfry`: the strfry bridge as its own service.
|
|
2
|
+
|
|
3
|
+
python -m fleetmesh_strfry run --dir NODE_DIR [--url ws://127.0.0.1:7777] [--every 30]
|
|
4
|
+
python -m fleetmesh_strfry plugin --dir NODE_DIR # strfry writePolicy stdio runner
|
|
5
|
+
python -m fleetmesh_strfry status|export|import|eval --dir NODE_DIR [flags]
|
|
6
|
+
|
|
7
|
+
`run` opens the node directory's stores the way `meshnode strfry` did, runs
|
|
8
|
+
a StrfryRelayBridge against the strfry URL, and drains the egress queue
|
|
9
|
+
every `--every` seconds until SIGTERM/SIGINT. The other verbs are the
|
|
10
|
+
former `meshnode strfry DIR ACTION` with `--dir` instead of a positional.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 on a clean stop or an accepted `eval`; 1 when `eval` rejects;
|
|
13
|
+
2 when the arguments are unusable (no such directory, no keys).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
from typing import Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from .bridge import StrfryRelayBridge
|
|
25
|
+
from .plugin import ACTIONS, STORE_FILE, add_strfry_arguments, cmd_strfry, open_stores
|
|
26
|
+
from .policy import DEFAULT_STRFRY_URL, StrfryPolicyConfig, StrfryPolicyEngine
|
|
27
|
+
|
|
28
|
+
EXIT_OK = 0
|
|
29
|
+
EXIT_USAGE = 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
ap = argparse.ArgumentParser(prog='python -m fleetmesh_strfry',
|
|
34
|
+
description=(__doc__ or '').split('\n\n')[0])
|
|
35
|
+
sub = ap.add_subparsers(dest='action', required=True)
|
|
36
|
+
|
|
37
|
+
run = sub.add_parser('run', help='bridge the node store and a strfry relay until stopped')
|
|
38
|
+
run.add_argument('--dir', required=True, help='the node directory (keys.json, store.sqlite, grants.sqlite)')
|
|
39
|
+
run.add_argument('--url', default=DEFAULT_STRFRY_URL, help='strfry WebSocket URL')
|
|
40
|
+
run.add_argument('--every', type=float, default=30.0, help='seconds between egress flushes')
|
|
41
|
+
run.add_argument('--store-file', default=STORE_FILE, help=argparse.SUPPRESS)
|
|
42
|
+
run.add_argument('--enforce-grants', action='store_true', help='enforce bilateral peering grants')
|
|
43
|
+
run.add_argument('--shadow', action='store_true', help='use shadowReject instead of reject')
|
|
44
|
+
run.add_argument('--iterations', type=int, default=0, help=argparse.SUPPRESS) # tests: stop after N flushes
|
|
45
|
+
|
|
46
|
+
for action in ACTIONS:
|
|
47
|
+
p = sub.add_parser(action, help='the former `meshnode strfry DIR %s`' % action)
|
|
48
|
+
p.add_argument('--dir', required=True, help='the node directory')
|
|
49
|
+
p.add_argument('--store-file', default=STORE_FILE, help=argparse.SUPPRESS)
|
|
50
|
+
p.add_argument('--json', action='store_true', help='print the structure, for scripts')
|
|
51
|
+
add_strfry_arguments(p)
|
|
52
|
+
return ap
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _usable(directory: str) -> Optional[str]:
|
|
56
|
+
if not os.path.isdir(directory):
|
|
57
|
+
return 'no such directory: %s' % directory
|
|
58
|
+
if not os.path.exists(os.path.join(directory, 'keys.json')):
|
|
59
|
+
return 'not a node directory (no keys.json): %s' % directory
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def serve(directory: str, url: str, every: float, store_file: str = STORE_FILE,
|
|
64
|
+
enforce_grants: bool = False, shadow: bool = False, iterations: int = 0,
|
|
65
|
+
stop: Optional[threading.Event] = None) -> int:
|
|
66
|
+
"""Run the bridge until `stop` is set (SIGTERM/SIGINT set it) or `iterations` flushes are done."""
|
|
67
|
+
st, gr = open_stores(directory, store_file)
|
|
68
|
+
engine = StrfryPolicyEngine(config=StrfryPolicyConfig(enforce_grants=enforce_grants, shadow_reject=shadow),
|
|
69
|
+
store_backend=st, grant_store=gr)
|
|
70
|
+
bridge = StrfryRelayBridge(store_backend=st, strfry_url=url, policy_engine=engine)
|
|
71
|
+
stop = stop or threading.Event()
|
|
72
|
+
|
|
73
|
+
def _on_signal(signum: int, _frame: object) -> None:
|
|
74
|
+
stop.set()
|
|
75
|
+
|
|
76
|
+
main_thread = threading.current_thread() is threading.main_thread()
|
|
77
|
+
if main_thread:
|
|
78
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
79
|
+
signal.signal(sig, _on_signal)
|
|
80
|
+
|
|
81
|
+
print('fleetmesh-strfry: bridging %s <-> %s every %gs' % (directory, url, every), file=sys.stderr, flush=True)
|
|
82
|
+
bridge.start()
|
|
83
|
+
done = 0
|
|
84
|
+
try:
|
|
85
|
+
while not stop.is_set():
|
|
86
|
+
if stop.wait(every):
|
|
87
|
+
break
|
|
88
|
+
sent = bridge.flush_egress()
|
|
89
|
+
done += 1
|
|
90
|
+
if sent:
|
|
91
|
+
print('fleetmesh-strfry: flushed %d egress events' % sent, file=sys.stderr, flush=True)
|
|
92
|
+
if iterations and done >= iterations:
|
|
93
|
+
break
|
|
94
|
+
finally:
|
|
95
|
+
bridge.stop()
|
|
96
|
+
for s in (st, gr):
|
|
97
|
+
close = getattr(s, 'close', None)
|
|
98
|
+
if callable(close):
|
|
99
|
+
try:
|
|
100
|
+
close()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
print('fleetmesh-strfry: stopped; %s' % bridge.stats(), file=sys.stderr, flush=True)
|
|
104
|
+
return EXIT_OK
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
108
|
+
ap = build_parser()
|
|
109
|
+
a = ap.parse_args(argv)
|
|
110
|
+
why = _usable(a.dir)
|
|
111
|
+
if why is not None:
|
|
112
|
+
print('fleetmesh-strfry: %s' % why, file=sys.stderr)
|
|
113
|
+
return EXIT_USAGE
|
|
114
|
+
if a.action == 'run':
|
|
115
|
+
return serve(a.dir, a.url, a.every, store_file=a.store_file, enforce_grants=a.enforce_grants,
|
|
116
|
+
shadow=a.shadow, iterations=a.iterations)
|
|
117
|
+
code = cmd_strfry(a)
|
|
118
|
+
return int(code) if isinstance(code, int) else EXIT_OK
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == '__main__':
|
|
122
|
+
sys.exit(main())
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Live Streaming Bridge (StrfryRelayBridge).
|
|
2
|
+
|
|
3
|
+
Real-time bidirectional streaming between a local/remote strfry relay (via WebSocket)
|
|
4
|
+
and the Fleetmesh node store and P2P gossip planes.
|
|
5
|
+
|
|
6
|
+
Moved verbatim from meshnode's node/strfry_bridge.py; only the import paths changed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Deque, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from node.protocols.nostr import client as relay
|
|
16
|
+
from node.interfaces import NostrEvent
|
|
17
|
+
|
|
18
|
+
from .policy import DEFAULT_STRFRY_URL, StrfryPolicyEngine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StrfryRelayBridge:
|
|
22
|
+
"""Live bidirectional WebSocket streaming bridge connecting strfry and Fleetmesh."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
store_backend: Any,
|
|
27
|
+
strfry_url: str = DEFAULT_STRFRY_URL,
|
|
28
|
+
policy_engine: Optional[StrfryPolicyEngine] = None,
|
|
29
|
+
kinds: Optional[Sequence[int]] = None,
|
|
30
|
+
clock: Optional[Callable[[], int]] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.store: Any = store_backend
|
|
33
|
+
self.strfry_url: str = strfry_url
|
|
34
|
+
self.policy: StrfryPolicyEngine = policy_engine or StrfryPolicyEngine(store_backend=store_backend)
|
|
35
|
+
self.kinds: list[int] = list(kinds or [0, 1, 3, 6, 7, 1059, 11801, 11802, 11803, 30801, 30802, 30803])
|
|
36
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
37
|
+
|
|
38
|
+
self.connected: bool = False
|
|
39
|
+
self.last_connected_at: Optional[int] = None
|
|
40
|
+
self.ingress_count: int = 0
|
|
41
|
+
self.egress_count: int = 0
|
|
42
|
+
self.errors_count: int = 0
|
|
43
|
+
self.last_error: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
self._relay: Optional[relay.Relay] = None
|
|
46
|
+
self._thread: Optional[threading.Thread] = None
|
|
47
|
+
self._stop_event: threading.Event = threading.Event()
|
|
48
|
+
self._egress_queue: Deque[NostrEvent] = collections.deque(maxlen=1000)
|
|
49
|
+
self._lock: threading.Lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
def enqueue(self, event: NostrEvent) -> None:
|
|
52
|
+
"""Queue `event` for strfry; it goes out on the next flush_egress.
|
|
53
|
+
|
|
54
|
+
The queue holds the newest 1000: when full, the oldest is dropped."""
|
|
55
|
+
if isinstance(event, dict):
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._egress_queue.append(event)
|
|
58
|
+
|
|
59
|
+
def start(self) -> None:
|
|
60
|
+
"""Launch background streaming bridge worker."""
|
|
61
|
+
with self._lock:
|
|
62
|
+
if self._thread is not None and self._thread.is_alive():
|
|
63
|
+
return
|
|
64
|
+
self._stop_event.clear()
|
|
65
|
+
self._thread = threading.Thread(target=self._run_loop, name=f'strfry-bridge-{self.strfry_url}', daemon=True)
|
|
66
|
+
self._thread.start()
|
|
67
|
+
|
|
68
|
+
def stop(self) -> None:
|
|
69
|
+
"""Stop background worker and close WebSocket."""
|
|
70
|
+
self._stop_event.set()
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._relay is not None:
|
|
73
|
+
try:
|
|
74
|
+
self._relay.close()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
self._relay = None
|
|
78
|
+
self.connected = False
|
|
79
|
+
if self._thread is not None and self._thread.is_alive():
|
|
80
|
+
self._thread.join(timeout=1.0)
|
|
81
|
+
self._thread = None
|
|
82
|
+
|
|
83
|
+
def publish_to_strfry(self, event: NostrEvent) -> tuple[bool, str]:
|
|
84
|
+
"""Publish an outbound event to the strfry relay after egress policy check."""
|
|
85
|
+
# 1. Egress Policy Evaluation
|
|
86
|
+
verdict = self.policy.evaluate_egress(event)
|
|
87
|
+
act = verdict.get('action', 'allow')
|
|
88
|
+
if act == 'drop':
|
|
89
|
+
return False, f"egress dropped: {verdict.get('msg', 'policy')}"
|
|
90
|
+
elif act == 'shadowDrop':
|
|
91
|
+
return True, "shadow dropped"
|
|
92
|
+
elif act == 'redact':
|
|
93
|
+
# A redacted event is the same event with fewer tags; its id and sig
|
|
94
|
+
# are deliberately left as they were - the relay is expected to
|
|
95
|
+
# refuse it, and the audit trail shows what was attempted.
|
|
96
|
+
event = {**event, 'tags': verdict.get('redacted_tags', [])}
|
|
97
|
+
|
|
98
|
+
# 2. WebSocket publication via dedicated relay client to prevent socket contention
|
|
99
|
+
pub_client = relay.Relay(self.strfry_url, timeout=5.0)
|
|
100
|
+
try:
|
|
101
|
+
pub_client.connect()
|
|
102
|
+
ok_pub, reason = pub_client.publish(event)
|
|
103
|
+
if ok_pub:
|
|
104
|
+
self.egress_count += 1
|
|
105
|
+
return ok_pub, reason
|
|
106
|
+
except Exception as e:
|
|
107
|
+
self.errors_count += 1
|
|
108
|
+
self.last_error = str(e)
|
|
109
|
+
self.enqueue(event)
|
|
110
|
+
return False, str(e)
|
|
111
|
+
finally:
|
|
112
|
+
pub_client.close()
|
|
113
|
+
|
|
114
|
+
def _run_loop(self) -> None:
|
|
115
|
+
consecutive_errors = 0
|
|
116
|
+
while not self._stop_event.is_set():
|
|
117
|
+
try:
|
|
118
|
+
r = relay.Relay(self.strfry_url, timeout=10.0)
|
|
119
|
+
r.connect()
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._relay = r
|
|
122
|
+
self.connected = True
|
|
123
|
+
self.last_connected_at = int(time.time())
|
|
124
|
+
self.last_error = None
|
|
125
|
+
consecutive_errors = 0
|
|
126
|
+
|
|
127
|
+
# Subscribe to strfry
|
|
128
|
+
sid = r.subscribe([{'kinds': self.kinds}], ready=False)
|
|
129
|
+
|
|
130
|
+
# Anything queued while strfry was away goes out now, not
|
|
131
|
+
# the next time strfry happens to send us something.
|
|
132
|
+
self.flush_egress()
|
|
133
|
+
|
|
134
|
+
# Process incoming stream
|
|
135
|
+
for ev in r.events(sid):
|
|
136
|
+
if self._stop_event.is_set():
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
# Pass through writePolicy engine
|
|
140
|
+
verdict = self.policy.evaluate_ingress({'type': 'new', 'event': ev})
|
|
141
|
+
if verdict.get('action') == 'accept':
|
|
142
|
+
self.ingress_count += 1
|
|
143
|
+
if self.policy.store is not self.store and hasattr(self.store, 'insert_event'):
|
|
144
|
+
self.store.insert_event(ev, self.clock())
|
|
145
|
+
if hasattr(self.store, 'note_origin'):
|
|
146
|
+
try:
|
|
147
|
+
self.store.note_origin(ev.get('id', ''), getattr(self.store, 'LOCAL', 'local'))
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# Flush pending egress
|
|
152
|
+
self.flush_egress()
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
consecutive_errors += 1
|
|
156
|
+
self.errors_count += 1
|
|
157
|
+
self.last_error = f'{type(e).__name__}: {e}'
|
|
158
|
+
with self._lock:
|
|
159
|
+
self.connected = False
|
|
160
|
+
if self._relay is not None:
|
|
161
|
+
try:
|
|
162
|
+
self._relay.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
self._relay = None
|
|
166
|
+
|
|
167
|
+
if not self._stop_event.is_set():
|
|
168
|
+
backoff = min(10.0, 0.5 * (2 ** min(consecutive_errors, 4)))
|
|
169
|
+
self._stop_event.wait(backoff)
|
|
170
|
+
|
|
171
|
+
def flush_egress(self) -> int:
|
|
172
|
+
"""Send queued egress to strfry; returns how many went out.
|
|
173
|
+
|
|
174
|
+
Driven from three places: after every (re)connect of the read loop,
|
|
175
|
+
after every inbound event, and from the plugin's tick() - so a quiet
|
|
176
|
+
strfry that sends nothing still gets what was queued for it. What
|
|
177
|
+
cannot be sent because strfry is unreachable is put back at the
|
|
178
|
+
front of the queue for the next attempt, in order.
|
|
179
|
+
"""
|
|
180
|
+
raw_items: list[NostrEvent] = []
|
|
181
|
+
with self._lock:
|
|
182
|
+
while self._egress_queue and len(raw_items) < 20:
|
|
183
|
+
raw_items.append(self._egress_queue.popleft())
|
|
184
|
+
if not raw_items:
|
|
185
|
+
return 0
|
|
186
|
+
items: list[NostrEvent] = []
|
|
187
|
+
for ev in raw_items:
|
|
188
|
+
verdict = self.policy.evaluate_egress(ev)
|
|
189
|
+
act = verdict.get('action', 'allow')
|
|
190
|
+
# A redacted event is a different event: its id and signature
|
|
191
|
+
# cover the tags, so strfry would refuse it. Withhold it instead.
|
|
192
|
+
if act in ('drop', 'shadowDrop', 'redact'):
|
|
193
|
+
continue
|
|
194
|
+
items.append(ev)
|
|
195
|
+
if not items:
|
|
196
|
+
return 0
|
|
197
|
+
sent = 0
|
|
198
|
+
pub_client = relay.Relay(self.strfry_url, timeout=5.0)
|
|
199
|
+
try:
|
|
200
|
+
pub_client.connect()
|
|
201
|
+
while items:
|
|
202
|
+
ev = items[0]
|
|
203
|
+
try:
|
|
204
|
+
ok_p, _ = pub_client.publish(ev)
|
|
205
|
+
except Exception:
|
|
206
|
+
break # connection gone; keep this and the rest
|
|
207
|
+
items.pop(0)
|
|
208
|
+
if ok_p:
|
|
209
|
+
self.egress_count += 1
|
|
210
|
+
sent += 1
|
|
211
|
+
# a relay that says "false" has judged the event; retrying it is pointless
|
|
212
|
+
except Exception as e:
|
|
213
|
+
self.last_error = f'{type(e).__name__}: {e}'
|
|
214
|
+
finally:
|
|
215
|
+
pub_client.close()
|
|
216
|
+
if items:
|
|
217
|
+
with self._lock:
|
|
218
|
+
self._egress_queue.extendleft(reversed(items))
|
|
219
|
+
return sent
|
|
220
|
+
|
|
221
|
+
def pending_egress(self) -> int:
|
|
222
|
+
with self._lock:
|
|
223
|
+
return len(self._egress_queue)
|
|
224
|
+
|
|
225
|
+
def stats(self) -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
'strfry_url': self.strfry_url,
|
|
228
|
+
'connected': self.connected,
|
|
229
|
+
'last_connected_at': self.last_connected_at,
|
|
230
|
+
'ingress_count': self.ingress_count,
|
|
231
|
+
'egress_count': self.egress_count,
|
|
232
|
+
'errors_count': self.errors_count,
|
|
233
|
+
'last_error': self.last_error,
|
|
234
|
+
'pending_egress': self.pending_egress(),
|
|
235
|
+
'policy': self.policy.stats(),
|
|
236
|
+
}
|
fleetmesh_strfry/pipe.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""High-throughput bulk stream pipe (StrfryPipe): JSONL export/import compatible with strfry.
|
|
2
|
+
|
|
3
|
+
- Streaming JSONL export (`NodeStore` -> strfry export format).
|
|
4
|
+
- Streaming JSONL import (`strfry export` -> `NodeStore`) with deduplication and origin tracking.
|
|
5
|
+
|
|
6
|
+
Moved verbatim from meshnode's node/strfry_bridge.py; only the import paths changed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable, Optional, TextIO
|
|
13
|
+
from fleetmesh_core.storage import store # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StrfryPipe:
|
|
17
|
+
"""High-throughput bulk streaming export and import compatible with strfry."""
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def export_jsonl(store_backend: Any, out_stream: TextIO) -> int:
|
|
21
|
+
"""Stream events from NodeStore out to JSONL (strfry export format)."""
|
|
22
|
+
count = 0
|
|
23
|
+
if hasattr(store_backend, 'db') and hasattr(store_backend.db, 'execute'):
|
|
24
|
+
cur = store_backend.db.execute('SELECT json FROM events ORDER BY created_at ASC, id ASC')
|
|
25
|
+
for row in cur:
|
|
26
|
+
out_stream.write(row[0] + '\n')
|
|
27
|
+
count += 1
|
|
28
|
+
elif hasattr(store_backend, 'all'):
|
|
29
|
+
for ev in store_backend.all():
|
|
30
|
+
out_stream.write(json.dumps(ev, separators=(',', ':')) + '\n')
|
|
31
|
+
count += 1
|
|
32
|
+
out_stream.flush()
|
|
33
|
+
return count
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def import_jsonl(
|
|
37
|
+
store_backend: Any,
|
|
38
|
+
in_stream: TextIO,
|
|
39
|
+
verify: bool = True,
|
|
40
|
+
clock_fn: Optional[Callable[[], int]] = None,
|
|
41
|
+
) -> tuple[int, int, int]:
|
|
42
|
+
"""Stream events from JSONL (strfry export format) into NodeStore.
|
|
43
|
+
|
|
44
|
+
Returns (imported, duplicates, errors).
|
|
45
|
+
"""
|
|
46
|
+
now = (clock_fn or (lambda: int(time.time())))()
|
|
47
|
+
imported = 0
|
|
48
|
+
duplicates = 0
|
|
49
|
+
errors = 0
|
|
50
|
+
|
|
51
|
+
for line in in_stream:
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
ev = json.loads(line)
|
|
58
|
+
if not isinstance(ev, dict) or 'id' not in ev:
|
|
59
|
+
errors += 1
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if verify and not store.verify_event(ev):
|
|
63
|
+
errors += 1
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
res = store_backend.insert_event(ev, now)
|
|
67
|
+
if res == 'stored':
|
|
68
|
+
imported += 1
|
|
69
|
+
if hasattr(store_backend, 'note_origin'):
|
|
70
|
+
store_backend.note_origin(ev['id'], getattr(store_backend, 'LOCAL', 'local'))
|
|
71
|
+
elif res == 'duplicate':
|
|
72
|
+
duplicates += 1
|
|
73
|
+
else:
|
|
74
|
+
errors += 1
|
|
75
|
+
except Exception:
|
|
76
|
+
errors += 1
|
|
77
|
+
|
|
78
|
+
return imported, duplicates, errors
|