hivemind-sqlite-database 0.4.0a7__tar.gz → 0.4.2a1__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.

Potentially problematic release.


This version of hivemind-sqlite-database might be problematic. Click here for more details.

Files changed (14) hide show
  1. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/PKG-INFO +1 -1
  2. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database/__init__.py +86 -20
  3. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database/version.py +2 -2
  4. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
  5. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/tests/test_sqlitedb.py +83 -0
  6. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/LICENSE.md +0 -0
  7. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/README.md +0 -0
  8. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
  9. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
  10. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
  11. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
  12. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
  13. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/pyproject.toml +0 -0
  14. {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.4.0a7
3
+ Version: 0.4.2a1
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
@@ -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
@@ -27,6 +27,10 @@ class SQLiteDB(AbstractDB):
27
27
  subfolder: str = "hivemind-core"
28
28
  password: Optional[str] = None
29
29
 
30
+ # How long SQLite waits for a file lock held by another connection
31
+ # before giving up with SQLITE_BUSY, in milliseconds.
32
+ BUSY_TIMEOUT_MS: ClassVar[int] = 10000
33
+
30
34
  def __post_init__(self):
31
35
  """
32
36
  Initialize the SQLiteDB connection.
@@ -38,14 +42,31 @@ class SQLiteDB(AbstractDB):
38
42
 
39
43
  When *password* is ``None`` (default) the standard ``sqlite3`` module
40
44
  is used and the database file is unencrypted.
45
+
46
+ Each thread gets its own connection (see :attr:`conn`). A single
47
+ shared connection is not usable from several threads at once: the
48
+ transaction state belongs to the connection, so one thread's
49
+ ``COMMIT`` ends another thread's transaction and resets its
50
+ in-flight statements. Under a threaded network backend that shows
51
+ up as ``sqlite3.ProgrammingError: bad parameter or other API
52
+ misuse`` and as writes that land with corrupted bindings.
41
53
  """
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)
54
+ self._db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db")
55
+ LOG.debug(f"sqlite database path: {self._db_path}")
56
+ os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
57
+
58
+ if self.password is not None and self.password == "":
59
+ raise ValueError("password must be non-empty when encryption is enabled")
45
60
 
61
+ self._write_lock = threading.Lock()
62
+ # opening the first connection also applies the WAL pragma
63
+ self.conn.execute("PRAGMA journal_mode=WAL")
64
+ self._initialize_database()
65
+ self._maybe_migrate()
66
+
67
+ def _connect(self):
68
+ """Open one new connection to the backing file."""
46
69
  if self.password is not None:
47
- if self.password == "":
48
- raise ValueError("password must be non-empty when encryption is enabled")
49
70
  try:
50
71
  import sqlcipher3 as _sqlcipher
51
72
  except ImportError:
@@ -54,18 +75,56 @@ class SQLiteDB(AbstractDB):
54
75
  "Install the system library (e.g. 'apt install libsqlcipher-dev') "
55
76
  "then: pip install hivemind-sqlite-database[cipher]"
56
77
  )
57
- self.conn = _sqlcipher.connect(db_path, check_same_thread=False)
58
- self.conn.row_factory = _sqlcipher.Row
78
+ conn = _sqlcipher.connect(self._db_path, check_same_thread=False)
79
+ conn.row_factory = _sqlcipher.Row
59
80
  escaped_password = self.password.replace("'", "''")
60
- self.conn.execute(f"PRAGMA key='{escaped_password}'")
81
+ conn.execute(f"PRAGMA key='{escaped_password}'")
61
82
  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()
83
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
84
+ conn.row_factory = sqlite3.Row
85
+ conn.execute(f"PRAGMA busy_timeout={int(self.BUSY_TIMEOUT_MS)}")
86
+ return conn
87
+
88
+ @property
89
+ def conn(self):
90
+ """The calling thread's own connection, opened on first use.
91
+
92
+ Connections are cheap and WAL lets many readers run beside one
93
+ writer, so a per-thread connection costs a file handle and buys
94
+ real thread safety. ``_write_lock`` still serialises writers
95
+ in-process so they do not fight over the file lock.
96
+ """
97
+ local = self._thread_state()
98
+ conn = getattr(local, "conn", None)
99
+ if conn is None:
100
+ conn = local.conn = self._connect()
101
+ return conn
102
+
103
+ @conn.setter
104
+ def conn(self, value) -> None:
105
+ """Adopt an already-open connection for the calling thread.
106
+
107
+ Only the calling thread sees it; other threads still open their
108
+ own. Tests use this to inject an in-memory database.
109
+ """
110
+ self._thread_state().conn = value
111
+
112
+ def _thread_state(self) -> threading.local:
113
+ local = self.__dict__.get("_local")
114
+ if local is None:
115
+ local = self.__dict__["_local"] = threading.local()
116
+ return local
117
+
118
+ def close(self) -> None:
119
+ """Close the calling thread's connection, if it has one."""
120
+ local = self._thread_state()
121
+ conn = getattr(local, "conn", None)
122
+ if conn is not None:
123
+ local.conn = None
124
+ try:
125
+ conn.close()
126
+ except sqlite3.Error as e:
127
+ LOG.error(f"Failed to close SQLite connection: {e}")
69
128
 
70
129
  def _initialize_database(self):
71
130
  """Initialize the database schema."""
@@ -92,6 +151,11 @@ class SQLiteDB(AbstractDB):
92
151
  metadata TEXT
93
152
  )
94
153
  """)
154
+ # api_key is looked up on every client connection admission
155
+ # (get_client_by_api_key), so a full table scan there is hot.
156
+ self.conn.execute(
157
+ "CREATE INDEX IF NOT EXISTS idx_clients_api_key ON clients(api_key)"
158
+ )
95
159
  columns = {
96
160
  row["name"]
97
161
  for row in self.conn.execute("PRAGMA table_info(clients)").fetchall()
@@ -230,10 +294,12 @@ class SQLiteDB(AbstractDB):
230
294
  LOG.error(f"Invalid search key: {key!r}")
231
295
  return []
232
296
  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]
297
+ # deliberately NOT wrapped in ``with self.conn`` — that context
298
+ # manager commits the connection's transaction on exit, which a
299
+ # read has no business doing.
300
+ cur = self.conn.execute(f"SELECT * FROM clients WHERE {key} = ?", (val,))
301
+ rows = cur.fetchall()
302
+ return [self._row_to_client(row) for row in rows]
237
303
  except sqlite3.Error as e:
238
304
  LOG.error(f"Failed to search clients in SQLite: {e}")
239
305
  return []
@@ -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 = 7
4
+ VERSION_BUILD = 2
5
+ VERSION_ALPHA = 1
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.0a7
3
+ Version: 0.4.2a1
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
@@ -783,6 +783,26 @@ class TestSQLiteDBGetClientByID(unittest.TestCase):
783
783
  self.assertEqual(db.refresh(1).allowed_types, ["y"])
784
784
 
785
785
 
786
+ class TestSQLiteDBApiKeyIndex(unittest.TestCase):
787
+ def test_index_exists_after_init(self):
788
+ db = make_db()
789
+ row = db.conn.execute(
790
+ "SELECT name FROM sqlite_master WHERE type='index' "
791
+ "AND name='idx_clients_api_key'"
792
+ ).fetchone()
793
+ self.assertIsNotNone(row)
794
+
795
+ def test_api_key_lookup_uses_index(self):
796
+ db = make_db()
797
+ plan = db.conn.execute(
798
+ "EXPLAIN QUERY PLAN SELECT * FROM clients WHERE api_key = ?",
799
+ ("k1",),
800
+ ).fetchall()
801
+ detail = " ".join(row["detail"] for row in plan)
802
+ self.assertIn("SEARCH", detail)
803
+ self.assertNotIn("SCAN", detail)
804
+
805
+
786
806
  class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
787
807
  """v2 schema: allowed_types + skill/intent blacklists (in metadata) survive
788
808
  add→search and add→refresh cycles without loss or mutation."""
@@ -863,3 +883,66 @@ class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
863
883
 
864
884
  if __name__ == "__main__":
865
885
  unittest.main()
886
+
887
+
888
+ class TestSQLiteDBThreadSafety(unittest.TestCase):
889
+ """A real file-backed DB hammered from many threads at once.
890
+
891
+ Reproduces the failure seen under the threaded (webrockets) network
892
+ backend: concurrent reads and writes through ONE shared connection
893
+ raise ``bad parameter or other API misuse`` and write rows with
894
+ corrupted bindings (``NOT NULL constraint failed: clients.api_key``),
895
+ because one thread's implicit COMMIT ends another thread's
896
+ transaction.
897
+ """
898
+
899
+ def test_concurrent_read_write_is_clean(self):
900
+ import tempfile
901
+ from unittest.mock import patch
902
+
903
+ with tempfile.TemporaryDirectory() as tmp:
904
+ with patch("hivemind_sqlite_database.xdg_data_home", return_value=tmp):
905
+ db = SQLiteDB()
906
+ n = 60
907
+ for i in range(n):
908
+ self.assertTrue(db.add_item(make_client(i, f"key-{i}")))
909
+
910
+ errors = []
911
+ misses = []
912
+ barrier = threading.Barrier(24)
913
+
914
+ def reader(i):
915
+ barrier.wait()
916
+ try:
917
+ for _ in range(40):
918
+ got = db.search_by_value("api_key", f"key-{i % n}")
919
+ if len(got) != 1:
920
+ misses.append((i, len(got)))
921
+ except Exception as e: # noqa: BLE001
922
+ errors.append(repr(e))
923
+
924
+ def writer(i):
925
+ barrier.wait()
926
+ try:
927
+ for k in range(40):
928
+ if not db.add_item(
929
+ make_client(i % n, f"key-{i % n}",
930
+ name=f"w{k}")):
931
+ errors.append(f"add_item returned False ({i},{k})")
932
+ except Exception as e: # noqa: BLE001
933
+ errors.append(repr(e))
934
+
935
+ threads = [threading.Thread(target=reader, args=(i,))
936
+ for i in range(16)]
937
+ threads += [threading.Thread(target=writer, args=(i,))
938
+ for i in range(8)]
939
+ for t in threads:
940
+ t.start()
941
+ for t in threads:
942
+ t.join(timeout=120)
943
+
944
+ self.assertEqual(errors, [])
945
+ self.assertEqual(misses, [])
946
+ # every api_key still resolves to exactly one live row
947
+ for i in range(n):
948
+ self.assertEqual(len(db.search_by_value("api_key", f"key-{i}")), 1)