hivemind-sqlite-database 0.4.1a1__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.
Files changed (14) hide show
  1. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/PKG-INFO +1 -1
  2. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database/__init__.py +81 -20
  3. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database/version.py +1 -1
  4. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
  5. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/tests/test_sqlitedb.py +63 -0
  6. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/LICENSE.md +0 -0
  7. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/README.md +0 -0
  8. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
  9. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
  10. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
  11. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
  12. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
  13. {hivemind_sqlite_database-0.4.1a1 → hivemind_sqlite_database-0.4.2a1}/pyproject.toml +0 -0
  14. {hivemind_sqlite_database-0.4.1a1 → 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.1a1
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")
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()
45
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."""
@@ -235,10 +294,12 @@ class SQLiteDB(AbstractDB):
235
294
  LOG.error(f"Invalid search key: {key!r}")
236
295
  return []
237
296
  try:
238
- with self.conn:
239
- cur = self.conn.execute(f"SELECT * FROM clients WHERE {key} = ?", (val,))
240
- rows = cur.fetchall()
241
- 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]
242
303
  except sqlite3.Error as e:
243
304
  LOG.error(f"Failed to search clients in SQLite: {e}")
244
305
  return []
@@ -1,7 +1,7 @@
1
1
  # START_VERSION_BLOCK
2
2
  VERSION_MAJOR = 0
3
3
  VERSION_MINOR = 4
4
- VERSION_BUILD = 1
4
+ VERSION_BUILD = 2
5
5
  VERSION_ALPHA = 1
6
6
  # END_VERSION_BLOCK
7
7
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.4.1a1
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
@@ -883,3 +883,66 @@ class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
883
883
 
884
884
  if __name__ == "__main__":
885
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)