Skip to content

Commit 00a957d

Browse files
committed
refactor(db): use atomic json_set for device and app field updates
Previously, updating a single field on a device or app required loading the entire user JSON object, modifying it in memory, and writing the whole object back to the database. This approach was inefficient and created potential race conditions where concurrent updates could overwrite each other. This commit refactors the update logic to use SQLite's `json_set` function for atomic and granular field updates. New helper functions, `update_device_field` and `update_app_field`, have been introduced to encapsulate this logic. All relevant routes in the manager and the websocket receiver have been updated to use these new functions, ensuring that changes are committed atomically and safely.
1 parent 415e0aa commit 00a957d

File tree

3 files changed

+298
-102
lines changed

3 files changed

+298
-102
lines changed

tronbyt_server/db.py

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
import shutil
77
import sqlite3
88
import string
9+
from contextlib import contextmanager
910
from datetime import date, datetime, timedelta
1011
from pathlib import Path
11-
from typing import Any, Literal
12+
from typing import Any, Literal, Generator
1213
from urllib.parse import quote, unquote
1314
from zoneinfo import ZoneInfo
1415

@@ -26,6 +27,20 @@
2627
logger = logging.getLogger(__name__)
2728

2829

30+
@contextmanager
31+
def db_transaction(
32+
db_conn: sqlite3.Connection,
33+
) -> Generator[sqlite3.Cursor, None, None]:
34+
"""A context manager for database transactions."""
35+
cursor = db_conn.cursor()
36+
try:
37+
yield cursor
38+
db_conn.commit()
39+
except sqlite3.Error:
40+
db_conn.rollback()
41+
raise
42+
43+
2944
def get_db() -> sqlite3.Connection:
3045
"""Get a database connection."""
3146
return sqlite3.connect(
@@ -971,6 +986,55 @@ def get_device_by_name(user: User, name: str) -> Device | None:
971986
return None
972987

973988

989+
def _update_json_field(
990+
cursor: sqlite3.Cursor,
991+
username: str,
992+
path: str,
993+
value: Any,
994+
) -> None:
995+
"""
996+
Update a single field in the json_data using the provided cursor.
997+
This function does NOT commit the transaction.
998+
"""
999+
cursor.execute(
1000+
"""
1001+
UPDATE json_data
1002+
SET data = json_set(data, ?, ?)
1003+
WHERE username = ?
1004+
""",
1005+
(path, value, username),
1006+
)
1007+
1008+
1009+
def update_device_field(
1010+
cursor: sqlite3.Cursor, username: str, device_id: str, field: str, value: Any
1011+
) -> None:
1012+
"""
1013+
Update a single field for a device using the provided cursor.
1014+
This function does NOT commit the transaction.
1015+
"""
1016+
path = f"$.devices.{device_id}.{field}"
1017+
_update_json_field(cursor, username, path, value)
1018+
logger.debug(f"Queued update for {field} for device {device_id}")
1019+
1020+
1021+
def update_app_field(
1022+
cursor: sqlite3.Cursor,
1023+
username: str,
1024+
device_id: str,
1025+
iname: str,
1026+
field: str,
1027+
value: Any,
1028+
) -> None:
1029+
"""
1030+
Update a single field for an app using the provided cursor.
1031+
This function does NOT commit the transaction.
1032+
"""
1033+
path = f"$.devices.{device_id}.apps.{iname}.{field}"
1034+
_update_json_field(cursor, username, path, value)
1035+
logger.debug(f"Queued update for {field} for app {iname} on device {device_id}")
1036+
1037+
9741038
def get_device_webp_dir(device_id: str, create: bool = True) -> Path:
9751039
"""Get the WebP directory for a device."""
9761040
path = get_data_dir() / "webp" / secure_filename(device_id)
@@ -981,19 +1045,45 @@ def get_device_webp_dir(device_id: str, create: bool = True) -> Path:
9811045

9821046
def get_device_by_id(db: sqlite3.Connection, device_id: str) -> Device | None:
9831047
"""Get a device by ID."""
984-
for user in get_all_users(db):
985-
device = user.devices.get(device_id)
986-
if device:
987-
return device
1048+
cursor = db.cursor()
1049+
cursor.execute(
1050+
"""
1051+
SELECT T2.value
1052+
FROM json_data AS T1, json_each(T1.data, '$.devices') AS T2
1053+
WHERE T2.key = ?
1054+
""",
1055+
(device_id,),
1056+
)
1057+
row = cursor.fetchone()
1058+
if row:
1059+
try:
1060+
device_data = json.loads(row[0])
1061+
return Device.model_validate(device_data)
1062+
except (json.JSONDecodeError, ValidationError) as e:
1063+
logger.error(f"Error processing device data for device {device_id}: {e}")
1064+
return None
9881065
return None
9891066

9901067

9911068
def get_user_by_device_id(db: sqlite3.Connection, device_id: str) -> User | None:
9921069
"""Get a user by device ID."""
993-
for user in get_all_users(db):
994-
device = user.devices.get(device_id)
995-
if device:
996-
return user
1070+
cursor = db.cursor()
1071+
cursor.execute(
1072+
"""
1073+
SELECT T1.data
1074+
FROM json_data AS T1, json_each(T1.data, '$.devices') AS T2
1075+
WHERE T2.key = ?
1076+
""",
1077+
(device_id,),
1078+
)
1079+
row = cursor.fetchone()
1080+
if row:
1081+
try:
1082+
user_data = json.loads(row[0])
1083+
return User.model_validate(user_data)
1084+
except (json.JSONDecodeError, ValidationError) as e:
1085+
logger.error(f"Error processing user data for device {device_id}: {e}")
1086+
return None
9971087
return None
9981088

9991089

@@ -1066,25 +1156,38 @@ def add_pushed_app(
10661156
)
10671157
return
10681158

1069-
device.apps[installation_id] = app
1070-
save_user(db, user)
1159+
save_app(db, device_id, app)
10711160

10721161

10731162
def save_app(db: sqlite3.Connection, device_id: str, app: App) -> bool:
1074-
"""Save an app."""
1163+
"""Save an app atomically using json_set."""
1164+
if not app.iname:
1165+
return True # Nothing to save if iname is missing
1166+
1167+
user = get_user_by_device_id(db, device_id)
1168+
if not user:
1169+
logger.error(f"Cannot save app: user not found for device {device_id}")
1170+
return False
1171+
10751172
try:
1076-
user = get_user_by_device_id(db, device_id)
1077-
if not user:
1078-
return False
1079-
if not app.iname:
1080-
return True
1081-
device = user.devices.get(device_id)
1082-
if not device:
1083-
return False
1084-
device.apps[app.iname] = app
1085-
save_user(db, user)
1173+
path = f"$.devices.{device_id}.apps.{app.iname}"
1174+
app_json = app.model_dump_json()
1175+
1176+
cursor = db.cursor()
1177+
cursor.execute(
1178+
"""
1179+
UPDATE json_data
1180+
SET data = json_set(data, ?, json(?))
1181+
WHERE username = ?
1182+
""",
1183+
(path, app_json, user.username),
1184+
)
1185+
db.commit()
1186+
logger.debug(f"Atomically saved app {app.iname} for user {user.username}")
10861187
return True
1087-
except Exception:
1188+
except sqlite3.Error as e:
1189+
logger.error(f"Could not save app {app.iname} for user {user.username}: {e}")
1190+
db.rollback()
10881191
return False
10891192

10901193

tronbyt_server/routers/manager.py

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,15 @@ def next_app_logic(
362362
# pinned app is set as the app iname in the device object, we need to clear it.
363363
if getattr(device, "pinned_app", None) == app.iname:
364364
logger.info(f"Unpinning app {app.iname} because fail or empty render")
365-
device.pinned_app = ""
366-
db.save_user(db_conn, user)
365+
try:
366+
with db.db_transaction(db_conn) as cursor:
367+
db.update_device_field(
368+
cursor, user.username, device.id, "pinned_app", ""
369+
)
370+
# Keep the in-memory object in sync
371+
device.pinned_app = ""
372+
except sqlite3.Error as e:
373+
logger.error(f"Failed to unpin app for device {device.id}: {e}")
367374

368375
# Pass next_index directly since it already points to the next app
369376
return next_app_logic(db_conn, user, device, next_index, recursion_depth + 1)
@@ -376,9 +383,16 @@ def next_app_logic(
376383
if webp_path.exists() and webp_path.stat().st_size > 0:
377384
# App rendered successfully - display it and save the index
378385
response = send_image(webp_path, device, app)
379-
# Save the next_index as the new last_app_index
380-
device.last_app_index = next_index
381-
db.save_user(db_conn, user)
386+
# Atomically save the next_index as the new last_app_index
387+
try:
388+
with db.db_transaction(db_conn) as cursor:
389+
db.update_device_field(
390+
cursor, user.username, device.id, "last_app_index", next_index
391+
)
392+
# Keep the in-memory object in sync for the current request
393+
device.last_app_index = next_index
394+
except sqlite3.Error as e:
395+
logger.error(f"Failed to update last_app_index for device {device.id}: {e}")
382396
return response
383397

384398
# WebP file doesn't exist or is empty - skip this app
@@ -578,8 +592,20 @@ async def update_brightness(
578592
content="Brightness must be between 0 and 5",
579593
)
580594

581-
device.brightness = db.ui_scale_to_percent(brightness)
582-
db.save_user(db_conn, user)
595+
percent_brightness = db.ui_scale_to_percent(brightness)
596+
try:
597+
with db.db_transaction(db_conn) as cursor:
598+
db.update_device_field(
599+
cursor, user.username, device.id, "brightness", percent_brightness
600+
)
601+
device.brightness = percent_brightness # keep in-memory model updated
602+
except sqlite3.Error as e:
603+
logger.error(f"Failed to update brightness for device {device.id}: {e}")
604+
raise HTTPException(
605+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
606+
detail="Database error",
607+
)
608+
583609
new_brightness_percent = db.get_device_brightness_percent(device)
584610

585611
# Send brightness command directly to active websocket connection (if any)
@@ -615,8 +641,18 @@ def update_interval(
615641
if not device:
616642
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
617643

618-
device.default_interval = interval
619-
db.save_user(db_conn, user)
644+
try:
645+
with db.db_transaction(db_conn) as cursor:
646+
db.update_device_field(
647+
cursor, user.username, device.id, "default_interval", interval
648+
)
649+
device.default_interval = interval
650+
except sqlite3.Error as e:
651+
logger.error(f"Failed to update interval for device {device.id}: {e}")
652+
return Response(
653+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
654+
content="Database error",
655+
)
620656
return Response(status_code=status.HTTP_200_OK)
621657

622658

@@ -1031,15 +1067,22 @@ def toggle_pin(
10311067
if iname not in device.apps:
10321068
return Response(status_code=status.HTTP_404_NOT_FOUND, content="App not found")
10331069

1034-
# Check if this app is currently pinned
1035-
if getattr(device, "pinned_app", None) == iname:
1036-
device.pinned_app = ""
1037-
flash(request, _("App unpinned."))
1038-
else:
1039-
device.pinned_app = iname
1040-
flash(request, _("App pinned."))
1070+
new_pinned_app = "" if getattr(device, "pinned_app", None) == iname else iname
1071+
1072+
try:
1073+
with db.db_transaction(db_conn) as cursor:
1074+
db.update_device_field(
1075+
cursor, user.username, device.id, "pinned_app", new_pinned_app
1076+
)
1077+
device.pinned_app = new_pinned_app
1078+
if new_pinned_app:
1079+
flash(request, _("App pinned."))
1080+
else:
1081+
flash(request, _("App unpinned."))
1082+
except sqlite3.Error as e:
1083+
logger.error(f"Failed to toggle pin for device {device.id}: {e}")
1084+
flash(request, _("Error updating pin status."), "error")
10411085

1042-
db.save_user(db_conn, user)
10431086
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
10441087

10451088

@@ -1296,9 +1339,23 @@ def toggle_enabled(
12961339
if not app:
12971340
return RedirectResponse(url="/", status_code=status.HTTP_404_NOT_FOUND)
12981341

1299-
app.enabled = not app.enabled
1300-
db.save_user(db_conn, user)
1301-
flash(request, _("Changes saved."))
1342+
new_enabled_status = not app.enabled
1343+
try:
1344+
with db.db_transaction(db_conn) as cursor:
1345+
db.update_app_field(
1346+
cursor,
1347+
user.username,
1348+
device.id,
1349+
app.iname,
1350+
"enabled",
1351+
new_enabled_status,
1352+
)
1353+
app.enabled = new_enabled_status
1354+
flash(request, _("Changes saved."))
1355+
except sqlite3.Error as e:
1356+
logger.error(f"Failed to toggle enabled for app {app.iname}: {e}")
1357+
flash(request, _("Error saving changes."), "error")
1358+
13021359
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
13031360

13041361

@@ -2094,9 +2151,30 @@ def next_app(
20942151
user = deps.user
20952152
device = deps.device
20962153

2097-
device.last_seen = datetime.now(timezone.utc)
2098-
device.info.protocol_type = ProtocolType.HTTP
2099-
db.save_user(db_conn, user)
2154+
now = datetime.now(timezone.utc)
2155+
try:
2156+
with db.db_transaction(db_conn) as cursor:
2157+
db.update_device_field(
2158+
cursor,
2159+
user.username,
2160+
device.id,
2161+
"last_seen",
2162+
now.isoformat(),
2163+
)
2164+
db.update_device_field(
2165+
cursor,
2166+
user.username,
2167+
device.id,
2168+
"info.protocol_type",
2169+
ProtocolType.HTTP.value,
2170+
)
2171+
# Keep in-memory object in sync
2172+
device.last_seen = now
2173+
device.info.protocol_type = ProtocolType.HTTP
2174+
except sqlite3.Error as e:
2175+
logger.error(
2176+
f"Failed to update device {device.id} last_seen and protocol_type: {e}"
2177+
)
21002178

21012179
return next_app_logic(db_conn, user, device)
21022180

0 commit comments

Comments
 (0)