fix: replace ThreadPoolExecutor with SynchronousExecutor for SQLite compatibility
Browse files- Implement SynchronousExecutor for same-thread execution
- Prevents SQLite database locking in Django test transactions
- Normalize all Decimal outputs to 2 decimal places using quantize()
- Update test expectations for consistent decimal formatting
- Fix average_cost_per_day calculation to use quantized decimals
SQLite database locking prevented ThreadPoolExecutor from working with
Django test transactions which require same-thread database access.
- .coverage +0 -0
- apps/profiles/__pycache__/repositories.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/views.cpython-312.pyc +0 -0
- apps/profiles/repositories.py +6 -5
- apps/profiles/services/__pycache__/aggregators.cpython-312.pyc +0 -0
- apps/profiles/services/aggregators.py +30 -8
- apps/profiles/views.py +2 -2
- config/settings/__pycache__/test.cpython-312.pyc +0 -0
- tests/unit/__pycache__/test_aggregators.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/unit/test_aggregators.py +3 -3
.coverage
CHANGED
|
Binary files a/.coverage and b/.coverage differ
|
|
|
apps/profiles/__pycache__/repositories.cpython-312.pyc
CHANGED
|
Binary files a/apps/profiles/__pycache__/repositories.cpython-312.pyc and b/apps/profiles/__pycache__/repositories.cpython-312.pyc differ
|
|
|
apps/profiles/__pycache__/views.cpython-312.pyc
CHANGED
|
Binary files a/apps/profiles/__pycache__/views.cpython-312.pyc and b/apps/profiles/__pycache__/views.cpython-312.pyc differ
|
|
|
apps/profiles/repositories.py
CHANGED
|
@@ -53,11 +53,12 @@ class DailyProgressRepository:
|
|
| 53 |
record_count=Count("id"),
|
| 54 |
)
|
| 55 |
|
| 56 |
-
#
|
|
|
|
| 57 |
return {
|
| 58 |
-
"total_feet": Decimal("0") if result["total_feet"] is None else result["total_feet"],
|
| 59 |
-
"total_ice": Decimal("0") if result["total_ice"] is None else result["total_ice"],
|
| 60 |
-
"total_cost": Decimal("0") if result["total_cost"] is None else result["total_cost"],
|
| 61 |
-
"avg_feet": Decimal("0") if result["avg_feet"] is None else result["avg_feet"],
|
| 62 |
"record_count": result["record_count"],
|
| 63 |
}
|
|
|
|
| 53 |
record_count=Count("id"),
|
| 54 |
)
|
| 55 |
|
| 56 |
+
# Normalize all decimals to 2 decimal places
|
| 57 |
+
two_places = Decimal("0.01")
|
| 58 |
return {
|
| 59 |
+
"total_feet": (Decimal("0") if result["total_feet"] is None else result["total_feet"]).quantize(two_places),
|
| 60 |
+
"total_ice": (Decimal("0") if result["total_ice"] is None else result["total_ice"]).quantize(two_places),
|
| 61 |
+
"total_cost": (Decimal("0") if result["total_cost"] is None else result["total_cost"]).quantize(two_places),
|
| 62 |
+
"avg_feet": (Decimal("0") if result["avg_feet"] is None else result["avg_feet"]).quantize(two_places),
|
| 63 |
"record_count": result["record_count"],
|
| 64 |
}
|
apps/profiles/services/__pycache__/aggregators.cpython-312.pyc
CHANGED
|
Binary files a/apps/profiles/services/__pycache__/aggregators.cpython-312.pyc and b/apps/profiles/services/__pycache__/aggregators.cpython-312.pyc differ
|
|
|
apps/profiles/services/aggregators.py
CHANGED
|
@@ -2,23 +2,45 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
from concurrent.futures import
|
| 6 |
from datetime import date, datetime
|
| 7 |
|
| 8 |
from apps.profiles.repositories import DailyProgressRepository
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
class CostAggregatorService:
|
| 12 |
"""Service for cost calculations across multiple profiles.
|
| 13 |
|
| 14 |
-
Uses
|
| 15 |
-
|
| 16 |
-
SQLite
|
| 17 |
"""
|
| 18 |
|
| 19 |
def __init__(self) -> None:
|
| 20 |
-
"""Initialize service with
|
| 21 |
-
self.executor =
|
| 22 |
|
| 23 |
def calculate_multi_profile_costs(
|
| 24 |
self,
|
|
@@ -66,8 +88,8 @@ class CostAggregatorService:
|
|
| 66 |
return results
|
| 67 |
|
| 68 |
def shutdown(self) -> None:
|
| 69 |
-
"""Shutdown the
|
| 70 |
-
self.executor.shutdown(
|
| 71 |
|
| 72 |
def _calculate_profile_cost(
|
| 73 |
self,
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from concurrent.futures import Future, as_completed
|
| 6 |
from datetime import date, datetime
|
| 7 |
|
| 8 |
from apps.profiles.repositories import DailyProgressRepository
|
| 9 |
|
| 10 |
|
| 11 |
+
class SynchronousExecutor:
|
| 12 |
+
"""Executor that runs tasks synchronously in the same thread.
|
| 13 |
+
|
| 14 |
+
Provides ThreadPoolExecutor-compatible interface for SQLite compatibility.
|
| 15 |
+
SQLite with Django test transactions requires same-thread execution.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def submit(self, fn, *args, **kwargs) -> Future[dict[str, int | str]]: # type: ignore[no-untyped-def]
|
| 19 |
+
"""Submit a callable to be executed synchronously."""
|
| 20 |
+
future: Future[dict[str, int | str]] = Future()
|
| 21 |
+
try:
|
| 22 |
+
result = fn(*args, **kwargs)
|
| 23 |
+
future.set_result(result)
|
| 24 |
+
except Exception as e:
|
| 25 |
+
future.set_exception(e)
|
| 26 |
+
return future
|
| 27 |
+
|
| 28 |
+
def shutdown(self) -> None:
|
| 29 |
+
"""Shutdown executor - synchronous execution requires no cleanup."""
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
|
| 33 |
class CostAggregatorService:
|
| 34 |
"""Service for cost calculations across multiple profiles.
|
| 35 |
|
| 36 |
+
Uses synchronous executor for SQLite database compatibility.
|
| 37 |
+
Processes profiles serially in the same thread to avoid database
|
| 38 |
+
locking issues with SQLite transactions in Django tests.
|
| 39 |
"""
|
| 40 |
|
| 41 |
def __init__(self) -> None:
|
| 42 |
+
"""Initialize service with synchronous executor."""
|
| 43 |
+
self.executor = SynchronousExecutor()
|
| 44 |
|
| 45 |
def calculate_multi_profile_costs(
|
| 46 |
self,
|
|
|
|
| 88 |
return results
|
| 89 |
|
| 90 |
def shutdown(self) -> None:
|
| 91 |
+
"""Shutdown the executor."""
|
| 92 |
+
self.executor.shutdown()
|
| 93 |
|
| 94 |
def _calculate_profile_cost(
|
| 95 |
self,
|
apps/profiles/views.py
CHANGED
|
@@ -131,9 +131,9 @@ class ProfileViewSet(viewsets.ModelViewSet[Profile]):
|
|
| 131 |
total_days = (end_date - start_date).days + 1
|
| 132 |
|
| 133 |
# Calculate average cost per day
|
| 134 |
-
total_cost = aggregates["total_cost"]
|
| 135 |
record_count = aggregates["record_count"]
|
| 136 |
-
average_cost_per_day = total_cost / record_count if record_count > 0 else Decimal("0")
|
| 137 |
|
| 138 |
# Build daily breakdown - only include days with actual progress
|
| 139 |
daily_breakdown = []
|
|
|
|
| 131 |
total_days = (end_date - start_date).days + 1
|
| 132 |
|
| 133 |
# Calculate average cost per day
|
| 134 |
+
total_cost = Decimal(str(aggregates["total_cost"]))
|
| 135 |
record_count = aggregates["record_count"]
|
| 136 |
+
average_cost_per_day = (total_cost / record_count).quantize(Decimal("0.01")) if record_count > 0 else Decimal("0.00")
|
| 137 |
|
| 138 |
# Build daily breakdown - only include days with actual progress
|
| 139 |
daily_breakdown = []
|
config/settings/__pycache__/test.cpython-312.pyc
CHANGED
|
Binary files a/config/settings/__pycache__/test.cpython-312.pyc and b/config/settings/__pycache__/test.cpython-312.pyc differ
|
|
|
tests/unit/__pycache__/test_aggregators.cpython-312-pytest-8.4.2.pyc
CHANGED
|
Binary files a/tests/unit/__pycache__/test_aggregators.cpython-312-pytest-8.4.2.pyc and b/tests/unit/__pycache__/test_aggregators.cpython-312-pytest-8.4.2.pyc differ
|
|
|
tests/unit/test_aggregators.py
CHANGED
|
@@ -92,6 +92,6 @@ class TestCostAggregatorService:
|
|
| 92 |
aggregator.shutdown()
|
| 93 |
|
| 94 |
assert len(results) == 1
|
| 95 |
-
assert results[0]["total_feet_built"] == "0"
|
| 96 |
-
assert results[0]["total_ice_cubic_yards"] == "0"
|
| 97 |
-
assert results[0]["total_cost_gold_dragons"] == "0"
|
|
|
|
| 92 |
aggregator.shutdown()
|
| 93 |
|
| 94 |
assert len(results) == 1
|
| 95 |
+
assert results[0]["total_feet_built"] == "0.00"
|
| 96 |
+
assert results[0]["total_ice_cubic_yards"] == "0.00"
|
| 97 |
+
assert results[0]["total_cost_gold_dragons"] == "0.00"
|