hivemind-sqlite-database 0.4.0a3__tar.gz → 0.4.3a8__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.
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/PKG-INFO +1 -1
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/README.md +6 -6
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database/__init__.py +122 -26
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database/version.py +2 -2
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/tests/test_sqlitedb.py +133 -2
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/LICENSE.md +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/pyproject.toml +0 -0
- {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/setup.cfg +0 -0
|
@@ -7,7 +7,7 @@ Implements the [`hivemind-plugin-manager`](https://github.com/JarbasHiveMind/hiv
|
|
|
7
7
|
encryption is enabled). Client records (API keys, crypto keys, access-control lists) are stored in
|
|
8
8
|
a local `.db` file with WAL journal mode for safe multi-reader, single-writer access.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
This is the default database backend for fresh hivemind-core installations.
|
|
11
11
|
|
|
12
12
|
## Where it fits
|
|
13
13
|
|
|
@@ -99,13 +99,13 @@ typically `~/.local/share/hivemind-core/clients.db`.
|
|
|
99
99
|
## Schema migration
|
|
100
100
|
|
|
101
101
|
On first open after an upgrade, `SQLiteDB` runs an automatic schema migration.
|
|
102
|
-
Version tracking uses SQLite's built-in `PRAGMA user_version
|
|
102
|
+
Version tracking uses SQLite's built-in `PRAGMA user_version`. There are no sibling files.
|
|
103
103
|
|
|
104
104
|
The v1→v2 migration folds legacy `intent_blacklist` / `skill_blacklist` column
|
|
105
105
|
data into each row's `metadata` JSON field and NULLs the legacy columns.
|
|
106
106
|
`message_blacklist` is purged outright.
|
|
107
107
|
|
|
108
|
-
The migration is idempotent
|
|
108
|
+
The migration is idempotent and crash-safe. The row rewrites
|
|
109
109
|
and the `user_version` bump happen in one transaction.
|
|
110
110
|
|
|
111
111
|
To migrate an existing installation to this backend, use hivemind-core's built-in
|
|
@@ -117,6 +117,6 @@ hivemind-core migrate-db
|
|
|
117
117
|
|
|
118
118
|
## Docs
|
|
119
119
|
|
|
120
|
-
- [docs/architecture.md](docs/architecture.md)
|
|
121
|
-
- [docs/configuration.md](docs/configuration.md)
|
|
122
|
-
- [docs/operations.md](docs/operations.md)
|
|
120
|
+
- [docs/architecture.md](docs/architecture.md): internals, WAL mode, schema, migration
|
|
121
|
+
- [docs/configuration.md](docs/configuration.md): full configuration reference
|
|
122
|
+
- [docs/operations.md](docs/operations.md): file locations, backup, encryption, authoring a plugin
|
|
@@ -2,7 +2,7 @@ import json
|
|
|
2
2
|
import os.path
|
|
3
3
|
import sqlite3
|
|
4
4
|
import threading
|
|
5
|
-
from typing import List, Optional, Union, Iterable
|
|
5
|
+
from typing import ClassVar, List, Optional, Union, Iterable
|
|
6
6
|
|
|
7
7
|
from ovos_utils.log import LOG
|
|
8
8
|
from ovos_utils.xdg_utils import xdg_data_home
|
|
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
|
|
15
15
|
_VALID_COLUMNS = frozenset({
|
|
16
16
|
"client_id", "api_key", "name", "description", "is_admin",
|
|
17
17
|
"last_seen", "intent_blacklist", "skill_blacklist", "message_blacklist",
|
|
18
|
-
"allowed_types", "
|
|
18
|
+
"allowed_types", "password",
|
|
19
19
|
"can_broadcast", "can_escalate", "can_propagate", "metadata",
|
|
20
20
|
})
|
|
21
21
|
|
|
@@ -26,6 +26,17 @@ class SQLiteDB(AbstractDB):
|
|
|
26
26
|
name: str = "clients"
|
|
27
27
|
subfolder: str = "hivemind-core"
|
|
28
28
|
password: Optional[str] = None
|
|
29
|
+
# Overrides the computed xdg path entirely, e.g. ":memory:" for tests.
|
|
30
|
+
# Every thread's connection is opened against this same target, so
|
|
31
|
+
# nothing ever silently falls back to the real client database.
|
|
32
|
+
# ":memory:" becomes a named shared in-memory database, one per
|
|
33
|
+
# SQLiteDB instance, so worker threads see the same tables as the
|
|
34
|
+
# thread that created them.
|
|
35
|
+
db_path: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
# How long SQLite waits for a file lock held by another connection
|
|
38
|
+
# before giving up with SQLITE_BUSY, in milliseconds.
|
|
39
|
+
BUSY_TIMEOUT_MS: ClassVar[int] = 10000
|
|
29
40
|
|
|
30
41
|
def __post_init__(self):
|
|
31
42
|
"""
|
|
@@ -38,14 +49,49 @@ class SQLiteDB(AbstractDB):
|
|
|
38
49
|
|
|
39
50
|
When *password* is ``None`` (default) the standard ``sqlite3`` module
|
|
40
51
|
is used and the database file is unencrypted.
|
|
52
|
+
|
|
53
|
+
Each thread gets its own connection (see :attr:`conn`). A single
|
|
54
|
+
shared connection is not usable from several threads at once: the
|
|
55
|
+
transaction state belongs to the connection, so one thread's
|
|
56
|
+
``COMMIT`` ends another thread's transaction and resets its
|
|
57
|
+
in-flight statements. Under a threaded network backend that shows
|
|
58
|
+
up as ``sqlite3.ProgrammingError: bad parameter or other API
|
|
59
|
+
misuse`` and as writes that land with corrupted bindings.
|
|
41
60
|
"""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
61
|
+
if self.db_path is not None:
|
|
62
|
+
self._db_path = self.db_path
|
|
63
|
+
if self._db_path == ":memory:":
|
|
64
|
+
# A plain ":memory:" database belongs to the one connection
|
|
65
|
+
# that opened it, so the next thread would open a second,
|
|
66
|
+
# empty database and find no tables. Give it a name and a
|
|
67
|
+
# shared cache instead. The name carries the instance id, so
|
|
68
|
+
# two in-memory databases in one process stay independent.
|
|
69
|
+
self._db_path = f"file:hivemind-{id(self):x}?mode=memory&cache=shared"
|
|
70
|
+
else:
|
|
71
|
+
self._db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db")
|
|
72
|
+
self._is_uri = self._db_path.startswith("file:")
|
|
73
|
+
parent = os.path.dirname(self._db_path)
|
|
74
|
+
if parent and not self._is_uri:
|
|
75
|
+
os.makedirs(parent, exist_ok=True)
|
|
76
|
+
LOG.debug(f"sqlite database path: {self._db_path}")
|
|
77
|
+
|
|
78
|
+
if self.password is not None and self.password == "":
|
|
79
|
+
raise ValueError("password must be non-empty when encryption is enabled")
|
|
45
80
|
|
|
81
|
+
self._write_lock = threading.Lock()
|
|
82
|
+
# opening the first connection also applies the WAL pragma
|
|
83
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
84
|
+
if "mode=memory" in self._db_path:
|
|
85
|
+
# A shared in-memory database lives only while a connection to
|
|
86
|
+
# it is open. Hold one for the lifetime of this object so the
|
|
87
|
+
# tables survive a thread closing its own connection.
|
|
88
|
+
self._keepalive = self._connect()
|
|
89
|
+
self._initialize_database()
|
|
90
|
+
self._maybe_migrate()
|
|
91
|
+
|
|
92
|
+
def _connect(self):
|
|
93
|
+
"""Open one new connection to the backing file."""
|
|
46
94
|
if self.password is not None:
|
|
47
|
-
if self.password == "":
|
|
48
|
-
raise ValueError("password must be non-empty when encryption is enabled")
|
|
49
95
|
try:
|
|
50
96
|
import sqlcipher3 as _sqlcipher
|
|
51
97
|
except ImportError:
|
|
@@ -54,18 +100,63 @@ class SQLiteDB(AbstractDB):
|
|
|
54
100
|
"Install the system library (e.g. 'apt install libsqlcipher-dev') "
|
|
55
101
|
"then: pip install hivemind-sqlite-database[cipher]"
|
|
56
102
|
)
|
|
57
|
-
|
|
58
|
-
|
|
103
|
+
conn = _sqlcipher.connect(self._db_path, check_same_thread=False,
|
|
104
|
+
uri=self._is_uri)
|
|
105
|
+
conn.row_factory = _sqlcipher.Row
|
|
59
106
|
escaped_password = self.password.replace("'", "''")
|
|
60
|
-
|
|
107
|
+
conn.execute(f"PRAGMA key='{escaped_password}'")
|
|
61
108
|
else:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
109
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False,
|
|
110
|
+
uri=self._is_uri)
|
|
111
|
+
conn.row_factory = sqlite3.Row
|
|
112
|
+
conn.execute(f"PRAGMA busy_timeout={int(self.BUSY_TIMEOUT_MS)}")
|
|
113
|
+
return conn
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def conn(self):
|
|
117
|
+
"""The calling thread's own connection, opened on first use.
|
|
118
|
+
|
|
119
|
+
Connections are cheap and WAL lets many readers run beside one
|
|
120
|
+
writer, so a per-thread connection costs a file handle and buys
|
|
121
|
+
real thread safety. ``_write_lock`` still serialises writers
|
|
122
|
+
in-process so they do not fight over the file lock.
|
|
123
|
+
"""
|
|
124
|
+
local = self._thread_state()
|
|
125
|
+
conn = getattr(local, "conn", None)
|
|
126
|
+
if conn is None:
|
|
127
|
+
conn = local.conn = self._connect()
|
|
128
|
+
return conn
|
|
129
|
+
|
|
130
|
+
@conn.setter
|
|
131
|
+
def conn(self, value) -> None:
|
|
132
|
+
"""Adopt an already-open connection for the calling thread only.
|
|
133
|
+
|
|
134
|
+
Any other thread that later touches ``.conn`` still opens its own
|
|
135
|
+
connection against ``self._db_path`` — if that is the real client
|
|
136
|
+
database, that thread silently writes to it. Pass ``db_path``
|
|
137
|
+
(e.g. ``":memory:"``) to the constructor instead so every thread,
|
|
138
|
+
present and future, agrees on the same target; this setter exists
|
|
139
|
+
only for single-threaded call sites that already hold a connection
|
|
140
|
+
they want reused.
|
|
141
|
+
"""
|
|
142
|
+
self._thread_state().conn = value
|
|
143
|
+
|
|
144
|
+
def _thread_state(self) -> threading.local:
|
|
145
|
+
local = self.__dict__.get("_local")
|
|
146
|
+
if local is None:
|
|
147
|
+
local = self.__dict__["_local"] = threading.local()
|
|
148
|
+
return local
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
"""Close the calling thread's connection, if it has one."""
|
|
152
|
+
local = self._thread_state()
|
|
153
|
+
conn = getattr(local, "conn", None)
|
|
154
|
+
if conn is not None:
|
|
155
|
+
local.conn = None
|
|
156
|
+
try:
|
|
157
|
+
conn.close()
|
|
158
|
+
except sqlite3.Error as e:
|
|
159
|
+
LOG.error(f"Failed to close SQLite connection: {e}")
|
|
69
160
|
|
|
70
161
|
def _initialize_database(self):
|
|
71
162
|
"""Initialize the database schema."""
|
|
@@ -84,7 +175,6 @@ class SQLiteDB(AbstractDB):
|
|
|
84
175
|
skill_blacklist TEXT,
|
|
85
176
|
message_blacklist TEXT,
|
|
86
177
|
allowed_types TEXT,
|
|
87
|
-
crypto_key VARCHAR(16),
|
|
88
178
|
password TEXT,
|
|
89
179
|
can_broadcast BOOLEAN DEFAULT TRUE,
|
|
90
180
|
can_escalate BOOLEAN DEFAULT TRUE,
|
|
@@ -92,6 +182,11 @@ class SQLiteDB(AbstractDB):
|
|
|
92
182
|
metadata TEXT
|
|
93
183
|
)
|
|
94
184
|
""")
|
|
185
|
+
# api_key is looked up on every client connection admission
|
|
186
|
+
# (get_client_by_api_key), so a full table scan there is hot.
|
|
187
|
+
self.conn.execute(
|
|
188
|
+
"CREATE INDEX IF NOT EXISTS idx_clients_api_key ON clients(api_key)"
|
|
189
|
+
)
|
|
95
190
|
columns = {
|
|
96
191
|
row["name"]
|
|
97
192
|
for row in self.conn.execute("PRAGMA table_info(clients)").fetchall()
|
|
@@ -191,9 +286,9 @@ class SQLiteDB(AbstractDB):
|
|
|
191
286
|
INSERT OR REPLACE INTO clients (
|
|
192
287
|
client_id, api_key, name, description, is_admin,
|
|
193
288
|
last_seen, intent_blacklist, skill_blacklist,
|
|
194
|
-
message_blacklist, allowed_types,
|
|
289
|
+
message_blacklist, allowed_types, password,
|
|
195
290
|
can_broadcast, can_escalate, can_propagate, metadata
|
|
196
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
291
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
197
292
|
""", (
|
|
198
293
|
client.client_id, client.api_key, client.name, client.description,
|
|
199
294
|
client.is_admin, client.last_seen,
|
|
@@ -206,7 +301,7 @@ class SQLiteDB(AbstractDB):
|
|
|
206
301
|
None,
|
|
207
302
|
None,
|
|
208
303
|
json.dumps(client.allowed_types),
|
|
209
|
-
client.
|
|
304
|
+
client.password,
|
|
210
305
|
client.can_broadcast, client.can_escalate, client.can_propagate,
|
|
211
306
|
metadata_json,
|
|
212
307
|
))
|
|
@@ -230,10 +325,12 @@ class SQLiteDB(AbstractDB):
|
|
|
230
325
|
LOG.error(f"Invalid search key: {key!r}")
|
|
231
326
|
return []
|
|
232
327
|
try:
|
|
233
|
-
with self.conn
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
328
|
+
# deliberately NOT wrapped in ``with self.conn`` — that context
|
|
329
|
+
# manager commits the connection's transaction on exit, which a
|
|
330
|
+
# read has no business doing.
|
|
331
|
+
cur = self.conn.execute(f"SELECT * FROM clients WHERE {key} = ?", (val,))
|
|
332
|
+
rows = cur.fetchall()
|
|
333
|
+
return [self._row_to_client(row) for row in rows]
|
|
237
334
|
except sqlite3.Error as e:
|
|
238
335
|
LOG.error(f"Failed to search clients in SQLite: {e}")
|
|
239
336
|
return []
|
|
@@ -322,7 +419,6 @@ class SQLiteDB(AbstractDB):
|
|
|
322
419
|
is_admin=bool(row["is_admin"]),
|
|
323
420
|
last_seen=row["last_seen"],
|
|
324
421
|
allowed_types=json.loads(row["allowed_types"] or "[]"),
|
|
325
|
-
crypto_key=row["crypto_key"],
|
|
326
422
|
password=row["password"],
|
|
327
423
|
can_broadcast=bool(row["can_broadcast"]),
|
|
328
424
|
can_escalate=bool(row["can_escalate"]),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# START_VERSION_BLOCK
|
|
2
2
|
VERSION_MAJOR = 0
|
|
3
3
|
VERSION_MINOR = 4
|
|
4
|
-
VERSION_BUILD =
|
|
5
|
-
VERSION_ALPHA =
|
|
4
|
+
VERSION_BUILD = 3
|
|
5
|
+
VERSION_ALPHA = 8
|
|
6
6
|
# END_VERSION_BLOCK
|
|
7
7
|
|
|
8
8
|
__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
|
{hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/tests/test_sqlitedb.py
RENAMED
|
@@ -389,7 +389,6 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
|
|
|
389
389
|
intent_blacklist=["a:b"],
|
|
390
390
|
skill_blacklist=["c:d"],
|
|
391
391
|
allowed_types=["recognizer_loop:utterance"],
|
|
392
|
-
crypto_key="1234567890123456",
|
|
393
392
|
password="secret",
|
|
394
393
|
can_broadcast=True,
|
|
395
394
|
can_escalate=False,
|
|
@@ -405,7 +404,6 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
|
|
|
405
404
|
self.assertEqual(r.description, "a test")
|
|
406
405
|
self.assertFalse(r.is_admin)
|
|
407
406
|
self.assertEqual(r.last_seen, 1234567890.0)
|
|
408
|
-
self.assertEqual(r.crypto_key, "1234567890123456")
|
|
409
407
|
self.assertEqual(r.password, "secret")
|
|
410
408
|
self.assertFalse(r.can_escalate)
|
|
411
409
|
# After SCHEMA_VERSION=2: legacy skill/intent kwargs auto-migrate
|
|
@@ -422,6 +420,56 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
|
|
|
422
420
|
self.assertEqual(r.intent_blacklist, ["a:b"])
|
|
423
421
|
|
|
424
422
|
|
|
423
|
+
class TestSQLiteDBPathOverride(unittest.TestCase):
|
|
424
|
+
"""A worker thread must never fall back to the real client database
|
|
425
|
+
just because the test only overrode `.conn` on the main thread."""
|
|
426
|
+
|
|
427
|
+
def test_db_path_override_keeps_worker_threads_off_disk(self):
|
|
428
|
+
import unittest.mock as mock
|
|
429
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
430
|
+
with mock.patch(
|
|
431
|
+
"hivemind_sqlite_database.xdg_data_home", return_value=tmpdir
|
|
432
|
+
):
|
|
433
|
+
db = SQLiteDB(name="clients", subfolder="hivemind-core",
|
|
434
|
+
db_path=":memory:")
|
|
435
|
+
|
|
436
|
+
# Read a row written on this thread. "SELECT 1" would answer
|
|
437
|
+
# the same against a private, empty database and so would
|
|
438
|
+
# never notice a per-thread database.
|
|
439
|
+
db.add_item(Client(client_id=1, api_key="key",
|
|
440
|
+
name="kitchen"))
|
|
441
|
+
|
|
442
|
+
errors = []
|
|
443
|
+
seen = []
|
|
444
|
+
|
|
445
|
+
def worker():
|
|
446
|
+
try:
|
|
447
|
+
seen.extend(db.search_by_value("name", "kitchen"))
|
|
448
|
+
except Exception as e: # noqa: BLE001
|
|
449
|
+
errors.append(e)
|
|
450
|
+
|
|
451
|
+
t = threading.Thread(target=worker)
|
|
452
|
+
t.start()
|
|
453
|
+
t.join()
|
|
454
|
+
|
|
455
|
+
self.assertEqual(errors, [])
|
|
456
|
+
self.assertEqual([c.client_id for c in seen], [1])
|
|
457
|
+
real_db_file = os.path.join(tmpdir, "hivemind-core", "clients.db")
|
|
458
|
+
self.assertFalse(os.path.exists(real_db_file))
|
|
459
|
+
|
|
460
|
+
def test_two_in_memory_databases_stay_independent(self):
|
|
461
|
+
first = SQLiteDB(db_path=":memory:")
|
|
462
|
+
second = SQLiteDB(db_path=":memory:")
|
|
463
|
+
first.add_item(Client(client_id=1, api_key="key", name="kitchen"))
|
|
464
|
+
self.assertEqual(second.search_by_value("name", "kitchen"), [])
|
|
465
|
+
|
|
466
|
+
def test_explicit_db_path_creates_missing_directories(self):
|
|
467
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
468
|
+
path = os.path.join(tmpdir, "newdir", "clients.db")
|
|
469
|
+
SQLiteDB(db_path=path)
|
|
470
|
+
self.assertTrue(os.path.isfile(path))
|
|
471
|
+
|
|
472
|
+
|
|
425
473
|
class TestSQLiteDBCommit(unittest.TestCase):
|
|
426
474
|
def test_commit_returns_true(self):
|
|
427
475
|
db = make_db()
|
|
@@ -783,6 +831,26 @@ class TestSQLiteDBGetClientByID(unittest.TestCase):
|
|
|
783
831
|
self.assertEqual(db.refresh(1).allowed_types, ["y"])
|
|
784
832
|
|
|
785
833
|
|
|
834
|
+
class TestSQLiteDBApiKeyIndex(unittest.TestCase):
|
|
835
|
+
def test_index_exists_after_init(self):
|
|
836
|
+
db = make_db()
|
|
837
|
+
row = db.conn.execute(
|
|
838
|
+
"SELECT name FROM sqlite_master WHERE type='index' "
|
|
839
|
+
"AND name='idx_clients_api_key'"
|
|
840
|
+
).fetchone()
|
|
841
|
+
self.assertIsNotNone(row)
|
|
842
|
+
|
|
843
|
+
def test_api_key_lookup_uses_index(self):
|
|
844
|
+
db = make_db()
|
|
845
|
+
plan = db.conn.execute(
|
|
846
|
+
"EXPLAIN QUERY PLAN SELECT * FROM clients WHERE api_key = ?",
|
|
847
|
+
("k1",),
|
|
848
|
+
).fetchall()
|
|
849
|
+
detail = " ".join(row["detail"] for row in plan)
|
|
850
|
+
self.assertIn("SEARCH", detail)
|
|
851
|
+
self.assertNotIn("SCAN", detail)
|
|
852
|
+
|
|
853
|
+
|
|
786
854
|
class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
|
|
787
855
|
"""v2 schema: allowed_types + skill/intent blacklists (in metadata) survive
|
|
788
856
|
add→search and add→refresh cycles without loss or mutation."""
|
|
@@ -863,3 +931,66 @@ class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
|
|
|
863
931
|
|
|
864
932
|
if __name__ == "__main__":
|
|
865
933
|
unittest.main()
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
class TestSQLiteDBThreadSafety(unittest.TestCase):
|
|
937
|
+
"""A real file-backed DB hammered from many threads at once.
|
|
938
|
+
|
|
939
|
+
Reproduces the failure seen under the threaded (webrockets) network
|
|
940
|
+
backend: concurrent reads and writes through ONE shared connection
|
|
941
|
+
raise ``bad parameter or other API misuse`` and write rows with
|
|
942
|
+
corrupted bindings (``NOT NULL constraint failed: clients.api_key``),
|
|
943
|
+
because one thread's implicit COMMIT ends another thread's
|
|
944
|
+
transaction.
|
|
945
|
+
"""
|
|
946
|
+
|
|
947
|
+
def test_concurrent_read_write_is_clean(self):
|
|
948
|
+
import tempfile
|
|
949
|
+
from unittest.mock import patch
|
|
950
|
+
|
|
951
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
952
|
+
with patch("hivemind_sqlite_database.xdg_data_home", return_value=tmp):
|
|
953
|
+
db = SQLiteDB()
|
|
954
|
+
n = 60
|
|
955
|
+
for i in range(n):
|
|
956
|
+
self.assertTrue(db.add_item(make_client(i, f"key-{i}")))
|
|
957
|
+
|
|
958
|
+
errors = []
|
|
959
|
+
misses = []
|
|
960
|
+
barrier = threading.Barrier(24)
|
|
961
|
+
|
|
962
|
+
def reader(i):
|
|
963
|
+
barrier.wait()
|
|
964
|
+
try:
|
|
965
|
+
for _ in range(40):
|
|
966
|
+
got = db.search_by_value("api_key", f"key-{i % n}")
|
|
967
|
+
if len(got) != 1:
|
|
968
|
+
misses.append((i, len(got)))
|
|
969
|
+
except Exception as e: # noqa: BLE001
|
|
970
|
+
errors.append(repr(e))
|
|
971
|
+
|
|
972
|
+
def writer(i):
|
|
973
|
+
barrier.wait()
|
|
974
|
+
try:
|
|
975
|
+
for k in range(40):
|
|
976
|
+
if not db.add_item(
|
|
977
|
+
make_client(i % n, f"key-{i % n}",
|
|
978
|
+
name=f"w{k}")):
|
|
979
|
+
errors.append(f"add_item returned False ({i},{k})")
|
|
980
|
+
except Exception as e: # noqa: BLE001
|
|
981
|
+
errors.append(repr(e))
|
|
982
|
+
|
|
983
|
+
threads = [threading.Thread(target=reader, args=(i,))
|
|
984
|
+
for i in range(16)]
|
|
985
|
+
threads += [threading.Thread(target=writer, args=(i,))
|
|
986
|
+
for i in range(8)]
|
|
987
|
+
for t in threads:
|
|
988
|
+
t.start()
|
|
989
|
+
for t in threads:
|
|
990
|
+
t.join(timeout=120)
|
|
991
|
+
|
|
992
|
+
self.assertEqual(errors, [])
|
|
993
|
+
self.assertEqual(misses, [])
|
|
994
|
+
# every api_key still resolves to exactly one live row
|
|
995
|
+
for i in range(n):
|
|
996
|
+
self.assertEqual(len(db.search_by_value("api_key", f"key-{i}")), 1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|