Skip to content

Commit c4cb309

Browse files
authored
Offline mode (#4)
1 parent 0c659d7 commit c4cb309

35 files changed

Lines changed: 1376 additions & 137 deletions

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,17 @@ with wildedge.track(handle):
191191
|---|---|---|---|
192192
| `dsn` | `-` | `WILDEDGE_DSN` | Required. `https://<secret>@ingest.wildedge.dev/<key>` |
193193
| `app_version` | `None` | `-` | Optional. Your app's version string. |
194+
| `app_identity` | `<project_key>` | `WILDEDGE_APP_IDENTITY` | Namespace for offline persistence paths. Set per-app to isolate multi-process workloads in one project. |
194195
| `debug` | `false` | `WILDEDGE_DEBUG` | Log events to console. |
195196
| `batch_size` | `10` | `-` | Events per transmission (recommended: 1-100). |
196197
| `flush_interval_sec` | `60` | `-` | Max seconds between flushes (recommended: 1-3600). |
197198
| `max_queue_size` | `200` | `-` | In-memory buffer limit (recommended: 10-10000). |
199+
| `enable_offline_persistence` | `true` | `-` | Persist pending unsent events on disk and replay on restart. |
200+
| `offline_queue_dir` | OS-specific state dir | `-` | Folder for pending queue persistence (defaults to platform state path). |
201+
| `max_event_age_sec` | `900` | `-` | Max age for queued events before dead-lettering. |
202+
| `enable_dead_letter_persistence` | `false` | `-` | Persist dropped batches/events to disk dead-letter store. |
203+
| `dead_letter_dir` | OS-specific cache dir | `-` | Directory where dead-letter batch files are stored. |
204+
| `max_dead_letter_batches` | `10` | `-` | Max dead-letter batch files retained on disk. |
198205

199206
## Testing
200207

scripts/run_compat_local.py

100644100755
File mode changed.

tests/test_batch.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from datetime import datetime, timezone
44

5-
from wildedge import config
5+
from wildedge import constants
66
from wildedge.batch import build_batch
77
from wildedge.device import DeviceInfo
88

@@ -24,7 +24,7 @@ def test_returns_protocol_version(self):
2424
session_id="sess-1",
2525
created_at=datetime.now(timezone.utc),
2626
)
27-
assert batch["protocol_version"] == config.PROTOCOL_VERSION
27+
assert batch["protocol_version"] == constants.PROTOCOL_VERSION
2828

2929
def test_includes_device(self):
3030
batch = build_batch(
@@ -58,6 +58,24 @@ def test_includes_events(self):
5858
)
5959
assert batch["events"] == events
6060

61+
def test_internal_queue_fields_are_not_sent(self):
62+
events = [
63+
{
64+
"event_type": "inference",
65+
"__we_first_queued_at": 1.0,
66+
"__we_attempts": 3,
67+
}
68+
]
69+
batch = build_batch(
70+
device=make_device(),
71+
models={},
72+
events=events,
73+
session_id="sess-1",
74+
created_at=datetime.now(timezone.utc),
75+
)
76+
assert "__we_first_queued_at" not in batch["events"][0]
77+
assert "__we_attempts" not in batch["events"][0]
78+
6179
def test_batch_id_is_unique(self):
6280
now = datetime.now(timezone.utc)
6381
b1 = build_batch(make_device(), {}, [], "s", now)

tests/test_cli.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from wildedge import cli
9+
from wildedge import cli, constants
1010
from wildedge.integrations.registry import IntegrationSpec
1111
from wildedge.runtime import bootstrap
1212
from wildedge.runtime import runner as runtime_runner
@@ -59,6 +59,9 @@ def fake_run(cmd, env, check): # type: ignore[no-untyped-def]
5959
assert captured["env"][bootstrap.RUN_PROPAGATE_ENV] == "1"
6060
assert captured["env"][bootstrap.RUN_STRICT_INTEGRATIONS_ENV] == "0"
6161
assert captured["env"][bootstrap.RUN_PRINT_STARTUP_REPORT_ENV] == "0"
62+
assert captured["env"][bootstrap.RUN_FLUSH_TIMEOUT_ENV] == str(
63+
constants.DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC
64+
)
6265

6366

6467
def test_cli_run_sets_no_propagate_and_strict(monkeypatch):
@@ -126,6 +129,34 @@ def test_install_runtime_requires_dsn(monkeypatch):
126129
bootstrap.install_runtime()
127130

128131

132+
def test_install_runtime_default_flush_timeout_is_shutdown_budget(monkeypatch):
133+
class FakeWildEdge:
134+
SUPPORTED_INTEGRATIONS = {"onnx"}
135+
136+
def __init__(self, *, dsn, app_version, debug): # type: ignore[no-untyped-def]
137+
pass
138+
139+
def instrument(self, name): # type: ignore[no-untyped-def]
140+
pass
141+
142+
def flush(self, timeout): # type: ignore[no-untyped-def]
143+
pass
144+
145+
def close(self): # type: ignore[no-untyped-def]
146+
pass
147+
148+
monkeypatch.setattr(bootstrap, "WildEdge", FakeWildEdge)
149+
monkeypatch.setenv(bootstrap.RUN_DSN_ENV, "https://secret@ingest.wildedge.dev/key")
150+
monkeypatch.delenv(bootstrap.RUN_FLUSH_TIMEOUT_ENV, raising=False)
151+
monkeypatch.setattr(bootstrap.importlib.util, "find_spec", lambda _: object())
152+
153+
context = bootstrap.install_runtime()
154+
try:
155+
assert context.flush_timeout == constants.DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC
156+
finally:
157+
context.shutdown()
158+
159+
129160
def test_install_runtime_instruments_requested_integrations(monkeypatch):
130161
events: list[tuple[str, str]] = []
131162

@@ -248,6 +279,53 @@ def test_doctor_runtime_config_fail(monkeypatch, capsys):
248279
assert "runtime_config: FAIL (batch_size out of range)" in out
249280

250281

282+
def test_doctor_reports_offline_and_dead_letter_checks(monkeypatch, capsys):
283+
monkeypatch.setenv("WILDEDGE_DSN", "https://secret@ingest.wildedge.dev/key")
284+
monkeypatch.setattr(cli.importlib.util, "find_spec", lambda _: object())
285+
monkeypatch.setattr(cli, "check_writable_dir", lambda _: (True, "ok"))
286+
rc = cli.main(["doctor", "--integrations", "onnx"])
287+
out = capsys.readouterr().out
288+
assert rc == 0
289+
assert "offline_queue_capacity: OK" in out
290+
assert "dead_letter_capacity: OK" in out
291+
assert "writable_offline_queue_dir: OK (ok)" in out
292+
assert "writable_dead_letter_dir: SKIP" in out
293+
294+
295+
def test_doctor_reports_dead_letter_dir_when_enabled(monkeypatch, capsys):
296+
monkeypatch.setenv("WILDEDGE_DSN", "https://secret@ingest.wildedge.dev/key")
297+
monkeypatch.setattr(cli.importlib.util, "find_spec", lambda _: object())
298+
monkeypatch.setattr(cli, "check_writable_dir", lambda _: (True, "ok"))
299+
rc = cli.main(["doctor", "--integrations", "onnx", "--dead-letter-persistence"])
300+
out = capsys.readouterr().out
301+
assert rc == 0
302+
assert "writable_dead_letter_dir: OK (ok)" in out
303+
304+
305+
def test_doctor_uses_project_key_for_default_namespace(monkeypatch, capsys):
306+
monkeypatch.setenv("WILDEDGE_DSN", "https://secret@ingest.wildedge.dev/test-prod")
307+
monkeypatch.setattr(cli.importlib.util, "find_spec", lambda _: object())
308+
monkeypatch.setattr(cli, "check_writable_dir", lambda path: (True, str(path)))
309+
monkeypatch.delenv("WILDEDGE_APP_IDENTITY", raising=False)
310+
rc = cli.main(["doctor", "--integrations", "onnx"])
311+
out = capsys.readouterr().out
312+
assert rc == 0
313+
assert "/test-prod/pending_queue" in out
314+
assert "/test-prod/dead_letters" in out
315+
316+
317+
def test_doctor_uses_app_identity_override_for_namespace(monkeypatch, capsys):
318+
monkeypatch.setenv("WILDEDGE_DSN", "https://secret@ingest.wildedge.dev/test-prod")
319+
monkeypatch.setenv("WILDEDGE_APP_IDENTITY", "my-app")
320+
monkeypatch.setattr(cli.importlib.util, "find_spec", lambda _: object())
321+
monkeypatch.setattr(cli, "check_writable_dir", lambda path: (True, str(path)))
322+
rc = cli.main(["doctor", "--integrations", "onnx"])
323+
out = capsys.readouterr().out
324+
assert rc == 0
325+
assert "/my-app/pending_queue" in out
326+
assert "/my-app/dead_letters" in out
327+
328+
251329
def test_runner_clears_runtime_env_when_no_propagate(monkeypatch):
252330
class FakeContext:
253331
debug = False

tests/test_client.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from wildedge import config
7+
from wildedge import constants
88

99

1010
@pytest.fixture(autouse=True)
@@ -15,6 +15,7 @@ def mock_dependencies():
1515
patch("wildedge.client.Transmitter"),
1616
patch("wildedge.client.Consumer"),
1717
patch("wildedge.client.EventQueue"),
18+
patch("wildedge.client.DeadLetterStore"),
1819
patch("wildedge.client.ModelRegistry"),
1920
):
2021
yield
@@ -25,7 +26,7 @@ def test_batch_size_too_low():
2526

2627
with pytest.raises(
2728
ValueError,
28-
match=f"batch_size must be between {config.BATCH_SIZE_MIN} and {config.BATCH_SIZE_MAX}",
29+
match=f"batch_size must be between {constants.BATCH_SIZE_MIN} and {constants.BATCH_SIZE_MAX}",
2930
):
3031
WildEdge(dsn="https://test@test.com/key", batch_size=0)
3132

@@ -35,7 +36,7 @@ def test_batch_size_too_high():
3536

3637
with pytest.raises(
3738
ValueError,
38-
match=f"batch_size must be between {config.BATCH_SIZE_MIN} and {config.BATCH_SIZE_MAX}",
39+
match=f"batch_size must be between {constants.BATCH_SIZE_MIN} and {constants.BATCH_SIZE_MAX}",
3940
):
4041
WildEdge(dsn="https://test@test.com/key", batch_size=101)
4142

@@ -45,7 +46,7 @@ def test_flush_interval_too_low():
4546

4647
with pytest.raises(
4748
ValueError,
48-
match=f"flush_interval_sec must be between {config.FLUSH_INTERVAL_MIN} and {config.FLUSH_INTERVAL_MAX}",
49+
match=f"flush_interval_sec must be between {constants.FLUSH_INTERVAL_MIN} and {constants.FLUSH_INTERVAL_MAX}",
4950
):
5051
WildEdge(dsn="https://test@test.com/key", flush_interval_sec=0)
5152

@@ -55,7 +56,7 @@ def test_flush_interval_too_high():
5556

5657
with pytest.raises(
5758
ValueError,
58-
match=f"flush_interval_sec must be between {config.FLUSH_INTERVAL_MIN} and {config.FLUSH_INTERVAL_MAX}",
59+
match=f"flush_interval_sec must be between {constants.FLUSH_INTERVAL_MIN} and {constants.FLUSH_INTERVAL_MAX}",
5960
):
6061
WildEdge(dsn="https://test@test.com/key", flush_interval_sec=3601)
6162

@@ -65,7 +66,7 @@ def test_max_queue_size_too_low():
6566

6667
with pytest.raises(
6768
ValueError,
68-
match=f"max_queue_size must be between {config.MAX_QUEUE_SIZE_MIN} and {config.MAX_QUEUE_SIZE_MAX}",
69+
match=f"max_queue_size must be between {constants.MAX_QUEUE_SIZE_MIN} and {constants.MAX_QUEUE_SIZE_MAX}",
6970
):
7071
WildEdge(dsn="https://test@test.com/key", max_queue_size=9)
7172

@@ -75,7 +76,7 @@ def test_max_queue_size_too_high():
7576

7677
with pytest.raises(
7778
ValueError,
78-
match=f"max_queue_size must be between {config.MAX_QUEUE_SIZE_MIN} and {config.MAX_QUEUE_SIZE_MAX}",
79+
match=f"max_queue_size must be between {constants.MAX_QUEUE_SIZE_MIN} and {constants.MAX_QUEUE_SIZE_MAX}",
7980
):
8081
WildEdge(dsn="https://test@test.com/key", max_queue_size=10001)
8182

@@ -91,3 +92,72 @@ def test_valid_values():
9192
max_queue_size=500,
9293
)
9394
assert client is not None
95+
96+
97+
def test_max_event_age_must_be_positive():
98+
from wildedge.client import WildEdge
99+
100+
with pytest.raises(ValueError, match="max_event_age_sec must be greater than 0"):
101+
WildEdge(dsn="https://test@test.com/key", max_event_age_sec=0)
102+
103+
104+
def test_max_dead_letter_batches_must_be_non_negative():
105+
from wildedge.client import WildEdge
106+
107+
with pytest.raises(ValueError, match="max_dead_letter_batches must be >= 0"):
108+
WildEdge(dsn="https://test@test.com/key", max_dead_letter_batches=-1)
109+
110+
111+
def test_app_identity_defaults_to_project_key():
112+
from wildedge.client import WildEdge
113+
114+
with (
115+
patch(
116+
"wildedge.client.default_pending_queue_dir", return_value="pending-dir"
117+
) as p,
118+
patch("wildedge.client.default_dead_letter_dir", return_value="dead-dir") as d,
119+
patch(
120+
"wildedge.client.default_model_registry_path", return_value="registry-path"
121+
) as r,
122+
):
123+
WildEdge(dsn="https://test@test.com/proj-key")
124+
p.assert_called_once_with("proj-key")
125+
d.assert_called_once_with("proj-key")
126+
r.assert_called_once_with("proj-key")
127+
128+
129+
def test_app_identity_override_used_for_paths():
130+
from wildedge.client import WildEdge
131+
132+
with (
133+
patch(
134+
"wildedge.client.default_pending_queue_dir", return_value="pending-dir"
135+
) as p,
136+
patch("wildedge.client.default_dead_letter_dir", return_value="dead-dir") as d,
137+
patch(
138+
"wildedge.client.default_model_registry_path", return_value="registry-path"
139+
) as r,
140+
):
141+
WildEdge(dsn="https://test@test.com/proj-key", app_identity="app-a")
142+
p.assert_called_once_with("app-a")
143+
d.assert_called_once_with("app-a")
144+
r.assert_called_once_with("app-a")
145+
146+
147+
def test_app_identity_env_override_used_for_paths(monkeypatch):
148+
from wildedge.client import WildEdge
149+
150+
monkeypatch.setenv(constants.ENV_APP_IDENTITY, "env-app")
151+
with (
152+
patch(
153+
"wildedge.client.default_pending_queue_dir", return_value="pending-dir"
154+
) as p,
155+
patch("wildedge.client.default_dead_letter_dir", return_value="dead-dir") as d,
156+
patch(
157+
"wildedge.client.default_model_registry_path", return_value="registry-path"
158+
) as r,
159+
):
160+
WildEdge(dsn="https://test@test.com/proj-key")
161+
p.assert_called_once_with("env-app")
162+
d.assert_called_once_with("env-app")
163+
r.assert_called_once_with("env-app")

0 commit comments

Comments
 (0)