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.
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/PKG-INFO +1 -1
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database/__init__.py +86 -20
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database/version.py +2 -2
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/tests/test_sqlitedb.py +83 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/LICENSE.md +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/README.md +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/pyproject.toml +0 -0
- {hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/setup.cfg +0 -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
|
-
|
|
43
|
-
LOG.debug(f"sqlite database path: {
|
|
44
|
-
os.makedirs(os.path.dirname(
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
81
|
+
conn.execute(f"PRAGMA key='{escaped_password}'")
|
|
61
82
|
else:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 =
|
|
5
|
-
VERSION_ALPHA =
|
|
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 "")
|
{hivemind_sqlite_database-0.4.0a7 → hivemind_sqlite_database-0.4.2a1}/tests/test_sqlitedb.py
RENAMED
|
@@ -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)
|
|
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
|