Skip to content

Commit b06d93b

Browse files
Backup and reset MWL on a schedule (#76)
The worklist is inherently ephemeral. Stale worklist items from a previous day are not meaningful to the modality and could cause confusion if they appeared in a C-FIND response. Therefore we backup and reset the MWL database on a configurable schedule (default: daily at 02:00 UTC).
1 parent 82e946b commit b06d93b

13 files changed

Lines changed: 320 additions & 137 deletions

.env.development

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ MWL_AET=SCREENING_MWL
1111
MWL_PORT=4243
1212
MWL_DB_PATH=/var/lib/pacs/worklist.db
1313

14+
BACKUP_PATH=/var/lib/pacs/backups
15+
1416
# PACS Server Configuration
1517
PACS_AET=SCREENING_PACS
1618
PACS_PORT=4244

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ _install-uv:
6969
# Database backup
7070
# ---------------------------------------------------------------------------
7171
backup-db: # Backup sqlite databases @Operations
72-
PYTHONPATH=scripts/python uv run python -m backup_database
72+
PYTHONPATH=src uv run python -m backup_main
7373

7474
# ---------------------------------------------------------------------------
7575
# Testing
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ADR-004: Daily backup and reset of the MWL database
2+
3+
Date: 2026-04-02
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
The gateway MWL database holds scheduled appointment data that is consumed by mammography modality via DICOM C-FIND. This data originates from Manage Breast Screening and is written to the gateway by the relay listener.
10+
11+
The worklist is inherently ephemeral: appointments are scheduled per clinic, and a clinic session does not span calendar days. Stale worklist items from a previous day are not meaningful to the modality and could cause confusion if they appeared in a C-FIND response.
12+
13+
The gateway MWL is not intended to be a canonical or long-term data store. Retaining worklist items indefinitely would cause the database to grow to unwieldy proportions and would give a false impression of data durability.
14+
15+
The gateway runs as a Python process on a Windows VM managed by Azure Arc. Scheduling therefore belongs in the infrastructure layer (Windows Task Scheduler, configurable via Arc).
16+
17+
## Decision
18+
19+
Reset the MWL database on a schedule managed by Windows Task Scheduler by:
20+
21+
1. Backing up the database before clearing it, using SQLite's native `conn.backup()` API
22+
2. Deleting all rows from `worklist_items`
23+
24+
`reset_main.py` is performs these two steps and exits. Windows Task Scheduler (registered via `scripts/bat/schtasks.bat`) invokes it on whatever schedule is configured in infrastructure.
25+
26+
**Alternatives considered:**
27+
28+
- **In-process Python scheduler** (`croniter` sleep loop) — puts scheduling logic in application code; duplicates a facility the OS already provides; requires a long-running process that serves no other purpose
29+
- **Reset on startup only** — would not handle long-running deployments where the process is not restarted between clinics
30+
- **No reset** — leads to unbounded growth and stale data visible to the modality
31+
32+
## Consequences
33+
34+
### Positive Consequences
35+
36+
- Worklist is clean at the start of each clinic with no manual intervention
37+
- Database size remains bounded
38+
- Backup before clear means data is recoverable if needed
39+
- Schedule is owned by infrastructure (Arc / Task Scheduler), not application code — no code change needed to adjust timing
40+
- `reset_main.py` is a simple, testable, one-shot script with no scheduler machinery
41+
42+
### Negative Consequences
43+
44+
- Schedule configuration lives outside the codebase; requires infrastructure access to change
45+
- Any worklist items written very close to the reset time could be cleared before the modality queries them. This is mitigated by choosing a reset time well outside clinic hours
46+
- Backups accumulate on disk and will need periodic pruning; this is not currently automated

docs/mwl/README.md

Lines changed: 58 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ The MWL server is a lightweight, production-ready DICOM worklist solution that:
99
- Provides scheduled procedure information via [DICOM C-FIND](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_C) protocol
1010
- Stores worklist items in SQLite database
1111
- Supports filtering by modality, date, and patient ID
12-
- Runs in a separate container alongside the [PACS Server](../pacs/README.md)
12+
- Resets the worklist daily via a scheduled `reset_main.py` script invoked by Windows Task Scheduler
13+
- Runs as a Python process on a Windows VM managed by Azure Arc
1314

1415
## Architecture
1516

@@ -21,54 +22,53 @@ The MWL server is a lightweight, production-ready DICOM worklist solution that:
2122
├─────────────────────────────────────────────────────────────┤
2223
│ │
2324
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
24-
│ │ C-FIND │─────▶│ Storage │─────▶│ SQLite │
25-
│ │ Handler │ │ Layer │ │ Database │ │
26-
│ └──────────────┘ └──────────────┘ └──────────┘ │
27-
│ │ ▲ │
28-
│ │ │ │
29-
│ └──────────────────────┘ │
30-
│ Query & Response │
31-
└─────────────────────────────────────────────────────────────┘
32-
33-
│ │
34-
┌──────┴──────┐ ───────────────
35-
│ Modality │ │ Relay Listener │
36-
│ (SCU) │ │ (Populates DB) │
37-
└─────────────┘ └────────────────┘
25+
│ │ C-FIND │─────▶│ Storage │─────▶│ SQLite │◀──┼──────────────────┐
26+
│ │ Handler │ │ Layer │ │ Database │ │
27+
│ └──────────────┘ └──────────────┘ └──────────┘ │
28+
│ │ ▲ │
29+
│ │ │ │ backup + clear
30+
│ └──────────────────────┘ │ (Task Scheduler)
31+
│ Query & Response │
32+
└─────────────────────────────────────────────────────────────┘
33+
┌───────┴────────┐
34+
│ reset_main.py
35+
┌──────┴──────┐ ┌───────┴────────┐ ────────────────┘
36+
│ Modality │ │ Relay Listener │
37+
│ (SCU) │ │ (Populates DB) │
38+
└─────────────┘ └────────────────┘
3839
```
3940

4041
### Workflow
4142

42-
1. **Worklist Creation**: Relay listener receives appointments from web app and creates worklist items (NB not yet implemented; worklist items must be created programmatically via `scripts/add_worklist_item.py`)
43+
1. **Worklist Creation**: Relay listener receives appointments from Manage Breast Screening and creates worklist items
4344
2. **Worklist Query**: Modality sends C-FIND request to MWL server
4445
3. **Filtering**: MWL server filters by modality, date, patient ID, status
4546
4. **Response**: Server returns matching worklist items to modality
46-
5. **Status Updates**: [MPPS (Modality Performed Procedure Step)](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_F) updates procedure status (NB not yet implemented)
47+
5. **Status Updates**: C-STORE receipt transitions items from `SCHEDULED` to `IN PROGRESS`; [MPPS](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_F) transitions items to `COMPLETED` or `DISCONTINUED`
48+
6. **Daily Reset**: Windows Task Scheduler invokes `reset_main.py`, which backs up and clears the database
4749

4850
## Running the MWL Server
4951

50-
The MWL server runs in a separate container:
52+
The gateway runs as a Python process on a Windows VM:
5153

5254
```bash
53-
# Start both PACS and MWL servers
54-
docker compose up -d
55+
# Start the MWL server
56+
uv run python -m mwl_main
5557

56-
# Start only MWL server
57-
docker compose up -d mwl
58-
59-
# View logs
60-
docker compose logs -f mwl
58+
# Run a one-shot backup and reset (normally invoked by Task Scheduler)
59+
uv run python -m reset_main
60+
```
6161

62-
# Stop servers
63-
docker compose down
62+
For local development, Docker Compose is available:
6463

65-
# Reset databases
66-
docker compose down -v
64+
```bash
65+
docker compose up -d mwl
66+
docker compose logs -f mwl
6767
```
6868

6969
## Configuration
7070

71-
Environment variables:
71+
### MWL server
7272

7373
| Variable | Default | Description |
7474
|----------|---------|-------------|
@@ -77,6 +77,26 @@ Environment variables:
7777
| `MWL_DB_PATH` | `/var/lib/pacs/worklist.db` | SQLite database path |
7878
| `LOG_LEVEL` | `INFO` | Logging level |
7979

80+
### Reset script (`reset_main.py`)
81+
82+
| Variable | Default | Description |
83+
|----------|---------|-------------|
84+
| `MWL_DB_PATH` | `/var/lib/pacs/worklist.db` | SQLite database path |
85+
| `BACKUP_PATH` | `/var/lib/pacs/backups` | Directory for database backups |
86+
| `LOG_LEVEL` | `INFO` | Logging level |
87+
88+
The reset schedule is configured in Windows Task Scheduler (registered via `scripts/bat/schtasks.bat`), not in the application.
89+
90+
## Scheduling the daily reset (Windows)
91+
92+
Register the scheduled task (run once, on the gateway VM):
93+
94+
```bat
95+
scripts\bat\schtasks.bat
96+
```
97+
98+
This creates a Task Scheduler task that runs `reset_main.py` daily at midnight. The schedule can be adjusted in Task Scheduler or via Azure Arc without any code change.
99+
80100
## Example query
81101

82102
```python
@@ -112,14 +132,14 @@ assoc.release()
112132
Check worklist items:
113133

114134
```bash
115-
docker compose exec gateway sqlite3 /var/lib/pacs/worklist.db \
135+
sqlite3 /var/lib/pacs/worklist.db \
116136
"SELECT accession_number, patient_name, scheduled_date, status FROM worklist_items;"
117137
```
118138

119139
Add test worklist item:
120140

121141
```bash
122-
docker compose exec gateway sqlite3 /var/lib/pacs/worklist.db <<EOF
142+
sqlite3 /var/lib/pacs/worklist.db <<EOF
123143
INSERT INTO worklist_items (
124144
accession_number, patient_id, patient_name, patient_birth_date,
125145
scheduled_date, scheduled_time, modality, study_description
@@ -139,31 +159,12 @@ uv run pytest tests/integration/test_c_find_returns_worklist_items.py -v
139159
uv run pytest tests/integration/test_request_cfind_on_worklist.py -v
140160
```
141161

142-
## Multi-container architecture
143-
144-
The PACS and MWL servers run in separate containers. See [ADR-003: Separate containers for PACS and MWL](../adr/ADR-003_Separate_containers_for_PACS_and_MWL.md) for the architectural decision and trade-offs.
145-
146-
**Docker Compose services:**
162+
## Worklist item status transitions
147163

148-
```yaml
149-
services:
150-
pacs:
151-
container_name: pacs-server
152-
command: ["uv", "run", "python", "-m", "pacs_main"]
153-
ports:
154-
- "4244:4244"
155-
156-
mwl:
157-
container_name: mwl-server
158-
command: ["uv", "run", "python", "-m", "mwl_main"]
159-
ports:
160-
- "4243:4243"
164+
```
165+
SCHEDULED ──(first C-STORE)──▶ IN PROGRESS ──(MPPS N-SET)──▶ COMPLETED
166+
167+
└────────(MPPS N-SET)──▶ DISCONTINUED
161168
```
162169

163-
Each server:
164-
165-
- Runs in its own container
166-
- Has its own Application Entity (AE)
167-
- Uses a separate SQLite database
168-
- Can be scaled and deployed independently
169-
- Handles different DICOM operations (C-STORE vs C-FIND)
170+
See [ADR-003: Separate containers for PACS and MWL](../adr/ADR-003_Separate_containers_for_PACS_and_MWL.md) and [ADR-004: Daily backup and reset of the MWL database](../adr/ADR-004_MWL_Daily_Backup_And_Reset.md) for architectural decisions.

scripts/bat/backup_database.bat

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
:: Batch file to run the backup_database.py script
1+
:: Backs up and resets the MWL worklist database. Intended to be run via Windows Task Scheduler.
2+
:: Assumes the project venv is at .venv\ relative to the root directory (uv default).
3+
:: Adjust the Python path below if the deployment creates the venv elsewhere.
24
@echo off
3-
set %rootdir=%1
4-
python "%rootdir%\scripts\python\backup_database.py"
5+
set rootdir=%~1
6+
"%rootdir%\.venv\Scripts\python.exe" -m mwl_reset

scripts/bat/schtasks.bat

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
:: This batch file creates a scheduled task to run the backup_database.bat script daily at midnight.
1+
:: Registers a daily scheduled task to back up and reset the MWL worklist database.
2+
:: Default schedule: daily at 02:00. Adjust /st to change the time.
3+
:: /f overwrites the task if it already exists.
24
@echo off
35
set thisdir=%~dp0
46
set rootdir="%thisdir%..\.."
5-
schtasks /create /tn BackupDatabaseTask /tr "%thisdir%backup_database.bat %rootdir%" /sc daily /st 00:00
7+
schtasks /create /tn MWLDailyReset /tr "%thisdir%backup_database.bat %rootdir%" /sc daily /st 02:00 /f

scripts/python/backup_database.py

Lines changed: 0 additions & 74 deletions
This file was deleted.

src/backup_main.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Entry point for backing up PACS and MWL databases."""
2+
3+
import logging
4+
import os
5+
import sys
6+
7+
from dotenv import load_dotenv
8+
9+
from db_backup import backup_database
10+
from telemetry import configure_telemetry
11+
12+
load_dotenv()
13+
configure_telemetry(service_name="backup")
14+
15+
16+
def main():
17+
"""
18+
Main entry point for database backup.
19+
20+
Environment variables:
21+
PACS_DB_PATH: Path to the PACS SQLite database
22+
MWL_DB_PATH: Path to the MWL SQLite database
23+
BACKUP_PATH: Directory for backups (default: ./backups)
24+
"""
25+
logging.basicConfig(
26+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
27+
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
28+
)
29+
30+
pacs_db_path = os.getenv("PACS_DB_PATH")
31+
mwl_db_path = os.getenv("MWL_DB_PATH")
32+
backup_path = os.getenv("BACKUP_PATH", "./backups")
33+
34+
success = True
35+
36+
if pacs_db_path:
37+
try:
38+
backup_database(pacs_db_path, backup_path)
39+
except Exception as e:
40+
logging.error(f"PACS backup failed: {e}")
41+
success = False
42+
else:
43+
logging.info("PACS_DB_PATH not set, skipping PACS backup")
44+
45+
if mwl_db_path:
46+
try:
47+
backup_database(mwl_db_path, backup_path)
48+
except Exception as e:
49+
logging.error(f"MWL backup failed: {e}")
50+
success = False
51+
else:
52+
logging.info("MWL_DB_PATH not set, skipping MWL backup")
53+
54+
sys.exit(0 if success else 1)
55+
56+
57+
if __name__ == "__main__":
58+
main()

0 commit comments

Comments
 (0)