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.
Files changed (14) hide show
  1. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/PKG-INFO +1 -1
  2. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/README.md +6 -6
  3. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database/__init__.py +122 -26
  4. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database/version.py +2 -2
  5. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
  6. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/tests/test_sqlitedb.py +133 -2
  7. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/LICENSE.md +0 -0
  8. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
  9. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
  10. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
  11. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
  12. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
  13. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/pyproject.toml +0 -0
  14. {hivemind_sqlite_database-0.4.0a3 → hivemind_sqlite_database-0.4.3a8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.4.0a3
3
+ Version: 0.4.3a8
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.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
- **This is the default database backend for fresh hivemind-core installations.**
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` — no sibling files.
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, crash-safe, and transactional — the row rewrites
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) — 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
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", "crypto_key", "password",
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
- db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db")
43
- LOG.debug(f"sqlite database path: {db_path}")
44
- os.makedirs(os.path.dirname(db_path), exist_ok=True)
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
- self.conn = _sqlcipher.connect(db_path, check_same_thread=False)
58
- self.conn.row_factory = _sqlcipher.Row
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
- self.conn.execute(f"PRAGMA key='{escaped_password}'")
107
+ conn.execute(f"PRAGMA key='{escaped_password}'")
61
108
  else:
62
- self.conn = sqlite3.connect(db_path, check_same_thread=False)
63
- self.conn.row_factory = sqlite3.Row
64
-
65
- self.conn.execute("PRAGMA journal_mode=WAL")
66
- self._write_lock = threading.Lock()
67
- self._initialize_database()
68
- self._maybe_migrate()
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, crypto_key, password,
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.crypto_key, client.password,
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
- cur = self.conn.execute(f"SELECT * FROM clients WHERE {key} = ?", (val,))
235
- rows = cur.fetchall()
236
- return [self._row_to_client(row) for row in rows]
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 = 0
5
- VERSION_ALPHA = 3
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 "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.4.0a3
3
+ Version: 0.4.3a8
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
@@ -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)