brainlessdb 0.14.2__tar.gz → 0.14.4__tar.gz
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.
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/CHANGELOG.md +18 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/PKG-INFO +2 -2
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/README.md +1 -1
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/__init__.py +1 -1
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/client.py +1 -1
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/collection.py +25 -7
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/pyproject.toml +1 -1
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_dead_letter.py +117 -4
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/uv.lock +1 -1
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/.gitignore +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/.python-version +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/bucket.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/fields.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/struct.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/cliff.toml +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/code_style_guide.md +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/justfile +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/test_results.txt +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/__init__.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/conftest.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/mock_nats.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_bucket.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_client.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_collection.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_deserialization.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_fields.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_flush_errors.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_load_errors.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_sync.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_thread_safety.py +0 -0
- {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_version.py +0 -0
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## [0.14.4] - 2026-06-01
|
|
7
|
+
|
|
8
|
+
### 📚 Documentation
|
|
9
|
+
|
|
10
|
+
- Dlq key format
|
|
11
|
+
|
|
12
|
+
### 🧪 Testing
|
|
13
|
+
|
|
14
|
+
- Pull state+local for new entities in config watcher, avoid `:` in DLQ key
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## [0.14.3] - 2026-06-01
|
|
18
|
+
|
|
19
|
+
### 🐛 Bug Fixes
|
|
20
|
+
|
|
21
|
+
- Pull state+local for new entities in config watcher, avoid `:` in DLQ key
|
|
22
|
+
|
|
23
|
+
|
|
6
24
|
## [0.14.2] - 2026-04-14
|
|
7
25
|
|
|
8
26
|
### 🐛 Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brainlessdb
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.4
|
|
4
4
|
Summary: Typed collections backed by NATS JetStream KV
|
|
5
5
|
Author-email: "INSOFT s.r.o." <helpdesk@insoft.cz>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -291,6 +291,6 @@ async for entry in bucket.watch(timeout=3):
|
|
|
291
291
|
|
|
292
292
|
Entities that fail to deserialize (e.g. after a schema change) are automatically moved to the `brainless_dlq` NATS KV bucket and removed from the source bucket. This applies during both initial load and live watch updates.
|
|
293
293
|
|
|
294
|
-
DLQ key format: `{SourceBucket}
|
|
294
|
+
DLQ key format: `{SourceBucket}__{EntityUID}__{Timestamp}` (double underscore — NATS KV keys disallow `:`)
|
|
295
295
|
|
|
296
296
|
Each entry contains `source_bucket`, `key`, `error`, `timestamp`, and the original `value` for recovery.
|
|
@@ -280,6 +280,6 @@ async for entry in bucket.watch(timeout=3):
|
|
|
280
280
|
|
|
281
281
|
Entities that fail to deserialize (e.g. after a schema change) are automatically moved to the `brainless_dlq` NATS KV bucket and removed from the source bucket. This applies during both initial load and live watch updates.
|
|
282
282
|
|
|
283
|
-
DLQ key format: `{SourceBucket}
|
|
283
|
+
DLQ key format: `{SourceBucket}__{EntityUID}__{Timestamp}` (double underscore — NATS KV keys disallow `:`)
|
|
284
284
|
|
|
285
285
|
Each entry contains `source_bucket`, `key`, `error`, `timestamp`, and the original `value` for recovery.
|
|
@@ -62,7 +62,7 @@ class BrainlessDB:
|
|
|
62
62
|
"value": original,
|
|
63
63
|
}
|
|
64
64
|
ts = entry["timestamp"].replace(":", "-").replace("+", "-")
|
|
65
|
-
dlq_key = f"{source_bucket}
|
|
65
|
+
dlq_key = f"{source_bucket}__{key}__{ts}"
|
|
66
66
|
payload = json.dumps(entry).encode()
|
|
67
67
|
try:
|
|
68
68
|
if self._dlq_bucket is None:
|
|
@@ -226,6 +226,20 @@ class Collection(Generic[T]):
|
|
|
226
226
|
_log.error("Failed to decode key '%s' from bucket '%s': %s", k, bucket.name, ex)
|
|
227
227
|
return result
|
|
228
228
|
|
|
229
|
+
@staticmethod
|
|
230
|
+
async def _fetch_extra(bucket: Optional[Bucket], uid: str) -> Optional[dict]:
|
|
231
|
+
"""Fetch a single state/local row for `uid`. Returns None if absent."""
|
|
232
|
+
if not bucket:
|
|
233
|
+
return None
|
|
234
|
+
raw = await bucket.get(uid)
|
|
235
|
+
if not raw:
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
return msgspec.json.decode(raw)
|
|
239
|
+
except Exception as ex:
|
|
240
|
+
_log.error("Failed to decode '%s' from bucket '%s': %s", uid, bucket.name, ex)
|
|
241
|
+
return None
|
|
242
|
+
|
|
229
243
|
# === CRUD ===
|
|
230
244
|
|
|
231
245
|
def add(self, obj: Union[T, dict[str, Any]]):
|
|
@@ -493,13 +507,17 @@ class Collection(Generic[T]):
|
|
|
493
507
|
self._metadata[uid] = w.m
|
|
494
508
|
if old:
|
|
495
509
|
self._index_remove(old)
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
510
|
+
state_data = self._to_state(old)
|
|
511
|
+
local_data = self._to_local(old)
|
|
512
|
+
else:
|
|
513
|
+
# New entity (created by another process) — pull state
|
|
514
|
+
# and local from their buckets, since _to_state(None)
|
|
515
|
+
# would yield no STATE-flagged fields and required
|
|
516
|
+
# fields would be missing.
|
|
517
|
+
state_data = await self._fetch_extra(self._state_bucket, uid)
|
|
518
|
+
local_data = await self._fetch_extra(self._local_bucket, uid)
|
|
519
|
+
|
|
520
|
+
new = self._from_parts(uid, w.d, state_data, local_data)
|
|
503
521
|
self._entities[uid] = new
|
|
504
522
|
self._index_add(new)
|
|
505
523
|
_register(new.uuid, self)
|
|
@@ -29,9 +29,12 @@ def _kv_config(name: str):
|
|
|
29
29
|
return type("C", (), {"bucket": name})()
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
DLQ_SEP = "__"
|
|
33
|
+
|
|
34
|
+
|
|
32
35
|
def _dlq_find(dlq_kv, bucket: str, uid: str) -> list[dict]:
|
|
33
|
-
"""Find all DLQ entries matching bucket
|
|
34
|
-
prefix = f"{bucket}
|
|
36
|
+
"""Find all DLQ entries matching the bucket+uid prefix."""
|
|
37
|
+
prefix = f"{bucket}{DLQ_SEP}{uid}{DLQ_SEP}"
|
|
35
38
|
results = []
|
|
36
39
|
for key, entry in dlq_kv._entries.items():
|
|
37
40
|
if key.startswith(prefix):
|
|
@@ -40,8 +43,8 @@ def _dlq_find(dlq_kv, bucket: str, uid: str) -> list[dict]:
|
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
def _dlq_has(dlq_kv, bucket: str, uid: str) -> bool:
|
|
43
|
-
"""Check if any DLQ entry exists for bucket
|
|
44
|
-
prefix = f"{bucket}
|
|
46
|
+
"""Check if any DLQ entry exists for the given bucket+uid."""
|
|
47
|
+
prefix = f"{bucket}{DLQ_SEP}{uid}{DLQ_SEP}"
|
|
45
48
|
return any(k.startswith(prefix) for k in dlq_kv._entries)
|
|
46
49
|
|
|
47
50
|
|
|
@@ -316,3 +319,113 @@ class TestDeadLetterClientAPI:
|
|
|
316
319
|
assert entries[0]["error"] == "error 2"
|
|
317
320
|
|
|
318
321
|
await db.stop()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class TestDeadLetterKeyFormat:
|
|
325
|
+
"""DLQ keys must be valid NATS KV keys (no `:`, `*`, `>`, spaces)."""
|
|
326
|
+
|
|
327
|
+
@pytest.mark.asyncio
|
|
328
|
+
async def test_dlq_key_has_no_colon(self):
|
|
329
|
+
"""Regression: NATS KV rejects `:` in keys, so the DLQ key must not contain one."""
|
|
330
|
+
nats = MockNats()
|
|
331
|
+
db = BrainlessDB(nats, namespace="test")
|
|
332
|
+
await db.start()
|
|
333
|
+
|
|
334
|
+
await db.dead_letter("SomeBucket", "some-key", b'{"v": 1}', "boom")
|
|
335
|
+
|
|
336
|
+
dlq_kv = nats.jetstream()._buckets["brainless_dlq"]
|
|
337
|
+
assert len(dlq_kv._entries) == 1
|
|
338
|
+
(key,) = dlq_kv._entries.keys()
|
|
339
|
+
assert ":" not in key
|
|
340
|
+
# And the parts we expect are still recoverable from the key
|
|
341
|
+
assert key.startswith("SomeBucket")
|
|
342
|
+
assert "some-key" in key
|
|
343
|
+
|
|
344
|
+
await db.stop()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TestWatcherStateMerge:
|
|
348
|
+
"""Live config-bucket watcher must merge state for brand-new entities."""
|
|
349
|
+
|
|
350
|
+
@pytest.mark.asyncio
|
|
351
|
+
async def test_new_entity_with_required_state_field_loads_via_watch(self):
|
|
352
|
+
"""Another process writes config+state; the watcher must build with state merged in,
|
|
353
|
+
not fail with 'missing required field'."""
|
|
354
|
+
from typing import Annotated
|
|
355
|
+
|
|
356
|
+
from msgspec import Meta
|
|
357
|
+
|
|
358
|
+
from brainlessdb import BrainlessBucket, BrainlessDBFeat
|
|
359
|
+
|
|
360
|
+
class StatefulV1(BrainlessBucket):
|
|
361
|
+
@staticmethod
|
|
362
|
+
def allows_app(app: str) -> bool:
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
# Required STATE field — no default. Pre-fix bug: build with state=None fails here.
|
|
366
|
+
name: Annotated[str, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})]
|
|
367
|
+
|
|
368
|
+
nats = MockNats()
|
|
369
|
+
db = BrainlessDB(nats, namespace="test", flush_interval=0)
|
|
370
|
+
items = db.collection(StatefulV1)
|
|
371
|
+
await db.start()
|
|
372
|
+
await asyncio.sleep(0.05) # let watcher initialize
|
|
373
|
+
|
|
374
|
+
# Simulate another process flushing a fresh entity. Brainless writes
|
|
375
|
+
# config then state per uid; we put state first so the row exists by
|
|
376
|
+
# the time the watcher processes the config event (the common case
|
|
377
|
+
# since the watcher is async and runs after both puts have landed).
|
|
378
|
+
js = nats.jetstream()
|
|
379
|
+
state_kv = await js.create_key_value(_kv_config("StatefulV1-State"))
|
|
380
|
+
config_kv = js._buckets["StatefulV1"]
|
|
381
|
+
uid = _uuid()
|
|
382
|
+
await state_kv.put(uid, msgspec.json.encode({"name": "alice"}))
|
|
383
|
+
await config_kv.put(uid, _make_config_wrapper({}))
|
|
384
|
+
|
|
385
|
+
await asyncio.sleep(0.1) # let watcher pick up the config event
|
|
386
|
+
|
|
387
|
+
# Entity loaded with state merged in — not dead-lettered
|
|
388
|
+
assert uid in items, "entity should have loaded via watcher"
|
|
389
|
+
assert items.get(uid).name == "alice"
|
|
390
|
+
dlq = js._buckets.get("brainless_dlq")
|
|
391
|
+
if dlq is not None:
|
|
392
|
+
assert not _dlq_has(dlq, "StatefulV1", uid)
|
|
393
|
+
|
|
394
|
+
await db.stop()
|
|
395
|
+
|
|
396
|
+
@pytest.mark.asyncio
|
|
397
|
+
async def test_new_entity_without_state_row_still_handles_required_state(self):
|
|
398
|
+
"""If state row genuinely doesn't exist when config arrives, dead-letter cleanly."""
|
|
399
|
+
from typing import Annotated
|
|
400
|
+
|
|
401
|
+
from msgspec import Meta
|
|
402
|
+
|
|
403
|
+
from brainlessdb import BrainlessBucket, BrainlessDBFeat
|
|
404
|
+
|
|
405
|
+
class StatefulV1(BrainlessBucket):
|
|
406
|
+
@staticmethod
|
|
407
|
+
def allows_app(app: str) -> bool:
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
name: Annotated[str, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})]
|
|
411
|
+
|
|
412
|
+
nats = MockNats()
|
|
413
|
+
db = BrainlessDB(nats, namespace="test", flush_interval=0)
|
|
414
|
+
items = db.collection(StatefulV1)
|
|
415
|
+
await db.start()
|
|
416
|
+
await asyncio.sleep(0.05)
|
|
417
|
+
|
|
418
|
+
# Config-only write — no state. Build must fail, row must be DLQ'd
|
|
419
|
+
# (and removed from source so the watcher doesn't loop on the same row).
|
|
420
|
+
js = nats.jetstream()
|
|
421
|
+
config_kv = js._buckets["StatefulV1"]
|
|
422
|
+
uid = _uuid()
|
|
423
|
+
await config_kv.put(uid, _make_config_wrapper({}))
|
|
424
|
+
await asyncio.sleep(0.1)
|
|
425
|
+
|
|
426
|
+
assert uid not in items
|
|
427
|
+
assert uid not in config_kv._entries, "bad row should be removed from source"
|
|
428
|
+
dlq = js._buckets["brainless_dlq"]
|
|
429
|
+
assert _dlq_has(dlq, "StatefulV1", uid)
|
|
430
|
+
|
|
431
|
+
await db.stop()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|