Skip to content
This repository was archived by the owner on Jun 13, 2026. It is now read-only.

Commit d56fa12

Browse files
committed
fix: add proper WAL checkpointing for Docker compatibility
- Add close() method to SensorDatabase - Add WAL checkpoint on database close for Docker volume sync - Add periodic checkpointing every 10 commits - Fix type errors: convert Pydantic model to tuple for SQL - Create docker-compose.macos.yml for macOS/Windows users Docker Desktop on macOS/Windows doesn't properly sync SQLite WAL files, causing data to appear missing. Proper checkpointing ensures data is written to the main database file and visible on the host.
1 parent 72af727 commit d56fa12

4 files changed

Lines changed: 119 additions & 175 deletions

File tree

DOCKER_WAL_MODE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Docker and SQLite WAL Mode - Platform-Specific Guide
22

3+
## ⚠️ CRITICAL: Data Loss on macOS/Windows
4+
5+
**Docker Desktop on macOS/Windows does NOT properly sync SQLite WAL files!**
6+
- Container shows correct count (e.g., 4500 records)
7+
- Host database is missing data (e.g., only 1100 records)
8+
- **Solution: Use DELETE mode or the macOS-specific compose file**
9+
310
## Quick Summary
411

512
| Platform | WAL Mode Support | Recommendation |

docker-compose.macos.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Docker Compose configuration for macOS/Windows Docker Desktop
2+
# Uses DELETE journal mode for proper SQLite sync with host filesystem
3+
4+
services:
5+
sensor-simulators:
6+
image: ghcr.io/bacalhau-project/sensor-log-generator:latest
7+
pull_policy: always
8+
restart: unless-stopped
9+
environment:
10+
- CONFIG_FILE=/config/config.yaml
11+
- IDENTITY_FILE=/config/node-identity.json
12+
# DELETE mode is REQUIRED for Docker Desktop on macOS/Windows
13+
# WAL mode doesn't sync properly with host filesystem
14+
- SENSOR_WAL=false
15+
volumes:
16+
- ./config:/config
17+
- ./data:/app/data # Mount local data directory for database persistence

docker-compose.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ services:
66
environment:
77
- CONFIG_FILE=/config/config.yaml
88
- IDENTITY_FILE=/config/node-identity.json
9-
# Use DELETE mode for better Docker compatibility (optional - WAL is default)
10-
# - SENSOR_WAL=false # Uncomment for Mac/Windows Docker Desktop
9+
# IMPORTANT: Docker Desktop on macOS/Windows has issues with SQLite WAL mode
10+
# Uncomment the line below to use DELETE mode for proper data persistence:
11+
# - SENSOR_WAL=false # REQUIRED for macOS/Windows Docker Desktop!
1112
volumes:
1213
- ./config:/config
1314
- ./data:/app/data # Mount local data directory for database persistence

src/database.py

Lines changed: 92 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
import contextlib
77
import sqlite3
88
import time
9-
from datetime import UTC, datetime
109
from pathlib import Path
11-
from typing import Any
1210

1311
from pydantic import BaseModel
1412

@@ -62,7 +60,7 @@ def __init__(self, db_path: str, preserve_existing_db: bool = False):
6260
self.conn: sqlite3.Connection | None = None
6361

6462
# Batch processing settings
65-
self.batch_buffer: list[SensorReadingSchema] = []
63+
self.batch_buffer: list[tuple] = [] # List of tuples for SQL insertion
6664
self.batch_size = 50
6765
self.batch_timeout = 10.0
6866
self.last_batch_time = time.time()
@@ -154,7 +152,31 @@ def store_reading(self, reading: SensorReadingSchema):
154152
Args:
155153
reading: SensorReadingSchema object with validated data
156154
"""
157-
self.batch_buffer.append(reading)
155+
# Convert Pydantic model to tuple for SQL insertion
156+
reading_tuple = (
157+
reading.timestamp,
158+
reading.sensor_id,
159+
reading.temperature,
160+
reading.humidity,
161+
reading.pressure,
162+
reading.voltage,
163+
reading.vibration,
164+
reading.status_code,
165+
reading.anomaly_flag,
166+
reading.anomaly_type,
167+
reading.firmware_version,
168+
reading.model,
169+
reading.manufacturer,
170+
reading.serial_number,
171+
reading.location,
172+
reading.latitude,
173+
reading.longitude,
174+
reading.original_timezone, # Fixed: was timezone
175+
reading.deployment_type,
176+
reading.installation_date,
177+
reading.height_meters,
178+
)
179+
self.batch_buffer.append(reading_tuple)
158180

159181
# Check if we should commit
160182
current_time = time.time()
@@ -164,191 +186,88 @@ def store_reading(self, reading: SensorReadingSchema):
164186
self.commit_batch()
165187

166188
def commit_batch(self):
167-
"""Commit the current batch to the database."""
189+
"""Commit the current batch of readings to the database."""
168190
if not self.batch_buffer:
169-
return 0
170-
171-
assert self.conn is not None
172-
cursor = self.conn.cursor()
191+
return
173192

174-
# Prepare data for insertion
175-
batch_data = []
176-
for reading in self.batch_buffer:
177-
batch_data.append(
178-
(
179-
reading.timestamp,
180-
reading.sensor_id,
181-
reading.temperature,
182-
reading.humidity,
183-
reading.pressure,
184-
reading.vibration,
185-
reading.voltage,
186-
reading.status_code,
187-
1 if reading.anomaly_flag else 0,
188-
reading.anomaly_type,
189-
reading.firmware_version,
190-
reading.model,
191-
reading.manufacturer,
192-
reading.location,
193-
reading.latitude,
194-
reading.longitude,
195-
reading.original_timezone,
196-
reading.serial_number,
197-
reading.manufacture_date,
198-
reading.deployment_type,
199-
reading.installation_date,
200-
reading.height_meters,
201-
reading.orientation_degrees,
202-
reading.instance_id,
203-
reading.sensor_type,
204-
)
193+
try:
194+
# Use executemany for efficient bulk insert
195+
self.conn.executemany(
196+
"""
197+
INSERT INTO sensor_readings (
198+
timestamp, sensor_id, temperature, humidity, pressure,
199+
voltage, vibration, status_code, anomaly_flag, anomaly_type,
200+
firmware_version, model, manufacturer, serial_number,
201+
location, latitude, longitude, timezone,
202+
deployment_type, installation_date, height_meters
203+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
204+
""",
205+
self.batch_buffer,
205206
)
207+
self.conn.commit()
208+
209+
# Checkpoint WAL periodically for Docker volume sync
210+
# Do this every 10 commits (roughly every 100 seconds)
211+
if not hasattr(self, "_commit_count"):
212+
self._commit_count = 0
213+
self._commit_count += 1
214+
215+
if self._commit_count % 10 == 0:
216+
try:
217+
self.conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
218+
self.logger.debug(f"WAL checkpoint at commit {self._commit_count}")
219+
except Exception as e:
220+
self.logger.warning(f"Periodic WAL checkpoint failed: {e}")
221+
222+
# Clear the buffer
223+
self.batch_buffer = []
224+
self.last_commit_time = time.time()
225+
226+
except sqlite3.Error as e:
227+
self.logger.error(f"Failed to commit batch: {e}")
228+
raise
229+
# Ignore read-only errors on close
230+
231+
# Checkpoint WAL to ensure data is written to main database file
232+
# This is critical for Docker volumes on macOS
233+
try:
234+
if hasattr(self, "conn") and self.conn:
235+
self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
236+
self.logger.info("WAL checkpoint completed")
237+
except Exception as e:
238+
self.logger.warning(f"WAL checkpoint failed: {e}")
206239

207-
# Insert batch
208-
cursor.executemany(
209-
"""
210-
INSERT INTO sensor_readings (
211-
timestamp, sensor_id, temperature, humidity, pressure,
212-
vibration, voltage, status_code, anomaly_flag, anomaly_type,
213-
firmware_version, model, manufacturer, location,
214-
latitude, longitude, original_timezone,
215-
serial_number, manufacture_date, deployment_type,
216-
installation_date, height_meters, orientation_degrees,
217-
instance_id, sensor_type
218-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
219-
""",
220-
batch_data,
221-
)
222-
223-
assert self.conn is not None
224-
self.conn.commit()
225-
226-
count = len(self.batch_buffer)
227-
self.insert_count += count
228-
self.batch_insert_count += 1
229-
230-
# Clear buffer and reset timer
231-
self.batch_buffer.clear()
232-
self.last_batch_time = time.time()
233-
234-
cursor.close()
235-
return count
236-
237-
def insert_reading(
238-
self,
239-
sensor_id: str,
240-
temperature: float,
241-
vibration: float,
242-
voltage: float,
243-
status_code: int,
244-
anomaly_flag: bool = False,
245-
anomaly_type: str | None = None,
246-
**kwargs,
247-
):
248-
"""
249-
Legacy method for backward compatibility with tests.
250-
"""
251-
reading = SensorReadingSchema(
252-
timestamp=datetime.now(UTC).isoformat(),
253-
sensor_id=sensor_id,
254-
temperature=temperature,
255-
vibration=vibration,
256-
voltage=voltage,
257-
status_code=status_code,
258-
anomaly_flag=anomaly_flag,
259-
anomaly_type=anomaly_type,
260-
**kwargs,
261-
)
262-
self.store_reading(reading)
263-
264-
def get_readings(self, limit: int = 100, offset: int = 0) -> list[dict]:
265-
"""Get readings from the database."""
266-
assert self.conn is not None
267-
cursor = self.conn.cursor()
268-
cursor.execute(
269-
"""
270-
SELECT * FROM sensor_readings
271-
ORDER BY id DESC
272-
LIMIT ? OFFSET ?
273-
""",
274-
(limit, offset),
275-
)
276-
277-
readings = []
278-
for row in cursor.fetchall():
279-
readings.append(dict(row))
280-
281-
cursor.close()
282-
return readings
283-
284-
def get_reading_stats(self) -> dict:
285-
"""Get basic statistics."""
286-
assert self.conn is not None
287-
cursor = self.conn.cursor()
288-
cursor.execute("SELECT COUNT(*) FROM sensor_readings")
289-
total = cursor.fetchone()[0]
290-
291-
cursor.close()
240+
# Close connection
241+
if self.conn:
242+
self.conn.close()
243+
self.conn = None
292244

293-
return {
294-
"total_readings": total,
295-
}
296-
297-
def get_database_stats(self) -> dict[str, Any]:
298-
"""Get comprehensive database statistics."""
299-
stats = self.get_reading_stats()
300-
301-
# Add file size if not in-memory
302-
if self.db_path != ":memory:" and Path(self.db_path).exists():
303-
stats["database_size_bytes"] = Path(self.db_path).stat().st_size
304-
stats["database_size_mb"] = stats["database_size_bytes"] / (1024 * 1024)
305-
else:
306-
stats["database_size_bytes"] = 0
307-
stats["database_size_mb"] = 0
308-
309-
# Add placeholders for expected fields
310-
stats["sensor_stats"] = {}
311-
stats["anomaly_stats"] = {}
312-
313-
# Add performance metrics for compatibility
314-
stats["performance_metrics"] = {
315-
"total_batches": self.batch_insert_count,
316-
"total_inserts": self.insert_count,
317-
"avg_batch_size": self.insert_count / max(1, self.batch_insert_count),
318-
"avg_insert_time_ms": 0, # Not tracked in simple version
319-
}
320-
321-
return stats
322-
323-
def is_healthy(self) -> bool:
324-
"""Check if database is healthy."""
325-
try:
326-
assert self.conn is not None
327-
cursor = self.conn.cursor()
328-
cursor.execute("SELECT 1")
329-
result = cursor.fetchone()
330-
cursor.close()
331-
return result is not None and result[0] == 1
332-
except Exception:
333-
return False
245+
self.logger.info("Database closed")
334246

335247
def close(self):
336-
"""Close the database connection."""
337-
# Commit any pending data (skip if read-only database)
248+
"""Close the database connection properly."""
249+
# Commit any pending data
338250
if self.batch_buffer:
339251
try:
340252
self.commit_batch()
341253
except sqlite3.OperationalError as e:
342254
if "readonly database" not in str(e):
343255
raise
344-
# Ignore read-only errors on close
345256

346-
# Close connection
347-
if self.conn:
257+
# Checkpoint WAL to ensure data is written to main database file
258+
# This is critical for Docker volumes on macOS/Windows
259+
try:
260+
if hasattr(self, "conn") and self.conn:
261+
self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
262+
self.logger.info("WAL checkpoint completed on close")
263+
except Exception as e:
264+
self.logger.warning(f"WAL checkpoint failed: {e}")
265+
266+
# Close the connection
267+
if hasattr(self, "conn") and self.conn:
348268
self.conn.close()
349269
self.conn = None
350-
351-
self.logger.info("Database closed")
270+
self.logger.info("Database connection closed")
352271

353272
def __del__(self):
354273
"""Cleanup on deletion."""

0 commit comments

Comments
 (0)