Skip to content

Commit 52da01a

Browse files
committed
make duckdb threadsafe
1 parent 02c3c16 commit 52da01a

1 file changed

Lines changed: 49 additions & 31 deletions

File tree

src/where_the_plow/db.py

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@
1010
class Database:
1111
def __init__(self, path: str):
1212
self.path = path
13+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1314
self.conn = duckdb.connect(path)
1415

16+
def _cursor(self) -> duckdb.DuckDBPyConnection:
17+
"""Create a thread-local cursor for safe concurrent access."""
18+
return self.conn.cursor()
19+
1520
def init(self):
16-
self.conn.execute("INSTALL spatial")
17-
self.conn.execute("LOAD spatial")
21+
cur = self._cursor()
22+
cur.execute("INSTALL spatial")
23+
cur.execute("LOAD spatial")
1824

19-
self.conn.execute("""
25+
cur.execute("""
2026
CREATE TABLE IF NOT EXISTS vehicles (
2127
vehicle_id VARCHAR PRIMARY KEY,
2228
description VARCHAR,
@@ -25,10 +31,10 @@ def init(self):
2531
last_seen TIMESTAMPTZ NOT NULL
2632
)
2733
""")
28-
self.conn.execute("""
34+
cur.execute("""
2935
CREATE SEQUENCE IF NOT EXISTS positions_seq
3036
""")
31-
self.conn.execute("""
37+
cur.execute("""
3238
CREATE TABLE IF NOT EXISTS positions (
3339
id BIGINT DEFAULT nextval('positions_seq'),
3440
vehicle_id VARCHAR NOT NULL,
@@ -43,27 +49,28 @@ def init(self):
4349
PRIMARY KEY (vehicle_id, timestamp)
4450
)
4551
""")
46-
self.conn.execute("""
52+
cur.execute("""
4753
CREATE INDEX IF NOT EXISTS idx_positions_time_geo
4854
ON positions (timestamp, latitude, longitude)
4955
""")
5056

5157
# Migration: add geom column to existing tables that lack it
52-
cols = self.conn.execute(
58+
cols = cur.execute(
5359
"SELECT column_name FROM information_schema.columns "
5460
"WHERE table_name='positions' AND column_name='geom'"
5561
).fetchall()
5662
if not cols:
57-
self.conn.execute("ALTER TABLE positions ADD COLUMN geom GEOMETRY")
63+
cur.execute("ALTER TABLE positions ADD COLUMN geom GEOMETRY")
5864

5965
# Backfill geom for existing rows
60-
self.conn.execute(
66+
cur.execute(
6167
"UPDATE positions SET geom = ST_Point(longitude, latitude) WHERE geom IS NULL"
6268
)
6369

6470
def upsert_vehicles(self, vehicles: list[dict], now: datetime):
71+
cur = self._cursor()
6572
for v in vehicles:
66-
self.conn.execute(
73+
cur.execute(
6774
"""
6875
INSERT INTO vehicles (vehicle_id, description, vehicle_type, first_seen, last_seen)
6976
VALUES (?, ?, ?, ?, ?)
@@ -78,9 +85,10 @@ def upsert_vehicles(self, vehicles: list[dict], now: datetime):
7885
def insert_positions(self, positions: list[dict], collected_at: datetime) -> int:
7986
if not positions:
8087
return 0
81-
count_before = self.conn.execute("SELECT count(*) FROM positions").fetchone()[0]
88+
cur = self._cursor()
89+
count_before = cur.execute("SELECT count(*) FROM positions").fetchone()[0]
8290
for p in positions:
83-
self.conn.execute(
91+
cur.execute(
8492
"""
8593
INSERT OR IGNORE INTO positions
8694
(vehicle_id, timestamp, collected_at, longitude, latitude, geom, bearing, speed, is_driving)
@@ -99,7 +107,7 @@ def insert_positions(self, positions: list[dict], collected_at: datetime) -> int
99107
p["is_driving"],
100108
],
101109
)
102-
count_after = self.conn.execute("SELECT count(*) FROM positions").fetchone()[0]
110+
count_after = cur.execute("SELECT count(*) FROM positions").fetchone()[0]
103111
return count_after - count_before
104112

105113
def get_latest_positions(
@@ -123,7 +131,7 @@ def get_latest_positions(
123131
ORDER BY timestamp ASC
124132
LIMIT $2
125133
"""
126-
rows = self.conn.execute(query, [after, limit]).fetchall()
134+
rows = self._cursor().execute(query, [after, limit]).fetchall()
127135
return [self._row_to_dict(r) for r in rows]
128136

129137
def get_nearby_vehicles(
@@ -154,7 +162,11 @@ def get_nearby_vehicles(
154162
ORDER BY timestamp ASC
155163
LIMIT $5
156164
"""
157-
rows = self.conn.execute(query, [lng, lat, radius_deg, after, limit]).fetchall()
165+
rows = (
166+
self._cursor()
167+
.execute(query, [lng, lat, radius_deg, after, limit])
168+
.fetchall()
169+
)
158170
return [self._row_to_dict(r) for r in rows]
159171

160172
def get_vehicle_history(
@@ -179,9 +191,11 @@ def get_vehicle_history(
179191
ORDER BY p.timestamp ASC
180192
LIMIT $5
181193
"""
182-
rows = self.conn.execute(
183-
query, [vehicle_id, since, until, after, limit]
184-
).fetchall()
194+
rows = (
195+
self._cursor()
196+
.execute(query, [vehicle_id, since, until, after, limit])
197+
.fetchall()
198+
)
185199
return [self._row_to_dict(r) for r in rows]
186200

187201
def get_coverage(
@@ -204,7 +218,7 @@ def get_coverage(
204218
ORDER BY p.timestamp ASC
205219
LIMIT $4
206220
"""
207-
rows = self.conn.execute(query, [since, until, after, limit]).fetchall()
221+
rows = self._cursor().execute(query, [since, until, after, limit]).fetchall()
208222
return [self._row_to_dict(r) for r in rows]
209223

210224
def get_coverage_trails(
@@ -224,7 +238,7 @@ def get_coverage_trails(
224238
AND p.timestamp <= $2
225239
ORDER BY p.vehicle_id, p.timestamp ASC
226240
"""
227-
rows = self.conn.execute(query, [since, until]).fetchall()
241+
rows = self._cursor().execute(query, [since, until]).fetchall()
228242

229243
trails = []
230244
for vid, group in groupby(rows, key=itemgetter(0)):
@@ -294,15 +308,18 @@ def _row_to_dict(self, row) -> dict:
294308
}
295309

296310
def get_stats(self) -> dict:
297-
total_positions = self.conn.execute(
298-
"SELECT count(*) FROM positions"
299-
).fetchone()[0]
300-
total_vehicles = self.conn.execute("SELECT count(*) FROM vehicles").fetchone()[
301-
0
302-
]
303-
active_vehicles = self.conn.execute(
311+
cur = self._cursor()
312+
row = cur.execute("SELECT count(*) FROM positions").fetchone()
313+
total_positions = row[0] if row else 0
314+
315+
row = cur.execute("SELECT count(*) FROM vehicles").fetchone()
316+
total_vehicles = row[0] if row else 0
317+
318+
row = cur.execute(
304319
"SELECT count(DISTINCT vehicle_id) FROM positions WHERE is_driving = 'maybe'"
305-
).fetchone()[0]
320+
).fetchone()
321+
active_vehicles = row[0] if row else 0
322+
306323
try:
307324
db_size_bytes = os.path.getsize(self.path)
308325
except OSError:
@@ -314,11 +331,12 @@ def get_stats(self) -> dict:
314331
"db_size_bytes": db_size_bytes,
315332
}
316333
if total_positions > 0:
317-
row = self.conn.execute(
334+
row = cur.execute(
318335
"SELECT min(timestamp), max(timestamp) FROM positions"
319336
).fetchone()
320-
result["earliest"] = row[0]
321-
result["latest"] = row[1]
337+
if row:
338+
result["earliest"] = row[0]
339+
result["latest"] = row[1]
322340
return result
323341

324342
def close(self):

0 commit comments

Comments
 (0)