Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ MWL_AET=SCREENING_MWL
MWL_PORT=4243
MWL_DB_PATH=/var/lib/pacs/worklist.db

# MWL Reset Scheduler Configuration
BACKUP_PATH=/var/lib/pacs/backups
MWL_RESET_SCHEDULE=0 2 * * *

# PACS Server Configuration
PACS_AET=SCREENING_PACS
PACS_PORT=4244
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ _install-uv:
# Database backup
# ---------------------------------------------------------------------------
backup-db: # Backup sqlite databases @Operations
PYTHONPATH=scripts/python uv run python -m backup_database
PYTHONPATH=src uv run python -m backup_main

# ---------------------------------------------------------------------------
# Testing
Expand Down
15 changes: 15 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ services:
condition: service_healthy
restart: unless-stopped

reset:
build:
context: .
dockerfile: Dockerfile
container_name: mwl-reset
command: ["uv", "run", "python", "-m", "reset_main"]
volumes:
- pacs-db:/var/lib/pacs
environment:
- MWL_DB_PATH=/var/lib/pacs/worklist.db
- BACKUP_PATH=/var/lib/pacs/backups
- MWL_RESET_SCHEDULE=0 2 * * *
- LOG_LEVEL=INFO
restart: unless-stopped

volumes:
pacs-storage:
driver: local
Expand Down
46 changes: 46 additions & 0 deletions docs/adr/ADR-004_MWL_Daily_Backup_And_Reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ADR-004: Daily backup and reset of the MWL database

Date: 2026-04-02

Status: Accepted

## Context

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.

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.

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.

## Decision

Reset the MWL database on a configurable schedule (default: daily at 02:00 UTC) by:

1. Backing up the database before clearing it, using SQLite's native `conn.backup()` API
2. Deleting all rows from `worklist_items`

This runs as a dedicated `reset` container (`reset_main.py` / `MWLResetScheduler`).

The schedule is configured via a cron expression (`MWL_RESET_SCHEDULE`), which allows any cadence (daily, weekly, etc) to be expressed in a single environment variable without code changes.

**Alternatives considered:**

- **Reset on startup only** — would not handle long-running deployments where the container is not restarted between clinics
- **No reset** — leads to unbounded growth and stale data visible to the modality
- **Interval-based env vars** (`RESET_INTERVAL=daily`, `RESET_TIME=02:00`, `RESET_DAY=monday`) — more verbose config, requires code changes to support new intervals; cron expression is more expressive in a single value

## Consequences

### Positive Consequences

- Worklist is clean at the start of each clinic with no manual intervention
- Database size remains bounded
- Backup before clear means data is recoverable if needed
- Schedule is fully configurable via env var without code changes
- Follows the existing one-process-per-container pattern

### Negative Consequences

- Adds a fifth service to the deployment
- 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
- Backups accumulate on disk and will need periodic pruning; this is not currently automated
93 changes: 55 additions & 38 deletions docs/mwl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The MWL server is a lightweight, production-ready DICOM worklist solution that:
- Provides scheduled procedure information via [DICOM C-FIND](https://dicom.nema.org/medical/dicom/current/output/html/part04.html#chapter_C) protocol
- Stores worklist items in SQLite database
- Supports filtering by modality, date, and patient ID
- Resets the worklist on a schedule, backing up the database before clearing it
- Runs in a separate container alongside the [PACS Server](../pacs/README.md)

## Architecture
Expand All @@ -21,54 +22,55 @@ The MWL server is a lightweight, production-ready DICOM worklist solution that:
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ C-FIND │─────▶│ Storage │─────▶│ SQLite │
│ │ Handler │ │ Layer │ │ Database │ │
│ └──────────────┘ └──────────────┘ └──────────┘ │
│ │ ▲ │
│ │ │ │
│ └──────────────────────┘ │
│ Query & Response │
└─────────────────────────────────────────────────────────────┘
│ │
┌──────┴──────┐ ───────────────
│ Modality │ │ Relay Listener │
│ (SCU) │ │ (Populates DB) │
└─────────────┘ └────────────────┘
│ │ C-FIND │─────▶│ Storage │─────▶│ SQLite │◀──┼──────────────────┐
│ │ Handler │ │ Layer │ │ Database │ │
│ └──────────────┘ └──────────────┘ └──────────┘ │
│ │ ▲ │
│ │ │ │ backup + clear (cron)
│ └──────────────────────┘ │
│ Query & Response │
└─────────────────────────────────────────────────────────────┘
┌──────┴─────────┐
│ Reset Scheduler
┌──────┴──────┐ ┌───────┴────────┐ ────────────────┘
│ Modality │ │ Relay Listener │
│ (SCU) │ │ (Populates DB) │
└─────────────┘ └────────────────┘
```

### Workflow

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`)
1. **Worklist Creation**: Relay listener receives appointments from Manage Breast Screening and creates worklist items
2. **Worklist Query**: Modality sends C-FIND request to MWL server
3. **Filtering**: MWL server filters by modality, date, patient ID, status
4. **Response**: Server returns matching worklist items to modality
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)
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`
6. **Reset**: Reset scheduler backs up and clears the database on a configurable schedule

## Running the MWL Server

The MWL server runs in a separate container:

```bash
# Start both PACS and MWL servers
# Start all services
docker compose up -d

# Start only MWL server
docker compose up -d mwl
# Start only the MWL server and reset scheduler
docker compose up -d mwl reset

# View logs
# View MWL server logs
docker compose logs -f mwl

# Stop servers
docker compose down
# View reset scheduler logs
docker compose logs -f reset

# Reset databases
docker compose down -v
# Stop all services
docker compose down
```

## Configuration

Environment variables:
### MWL server

| Variable | Default | Description |
|----------|---------|-------------|
Expand All @@ -77,6 +79,23 @@ Environment variables:
| `MWL_DB_PATH` | `/var/lib/pacs/worklist.db` | SQLite database path |
| `LOG_LEVEL` | `INFO` | Logging level |

### Reset scheduler

| Variable | Default | Description |
|----------|---------|-------------|
| `MWL_DB_PATH` | `/var/lib/pacs/worklist.db` | SQLite database path |
| `BACKUP_PATH` | `/var/lib/pacs/backups` | Directory for database backups |
| `MWL_RESET_SCHEDULE` | `0 2 * * *` | Cron expression for reset schedule (UTC) |
| `LOG_LEVEL` | `INFO` | Logging level |

The `MWL_RESET_SCHEDULE` value is a standard cron expression. Examples:

| Expression | Schedule |
|------------|----------|
| `0 2 * * *` | Daily at 02:00 UTC (default) |
| `0 2 * * 1` | Every Monday at 02:00 UTC |
| `0 2 1 * *` | First day of each month at 02:00 UTC |

## Example query

```python
Expand Down Expand Up @@ -141,29 +160,27 @@ uv run pytest tests/integration/test_request_cfind_on_worklist.py -v

## Multi-container architecture

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.
The MWL-related services run in separate containers. 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 the architectural decisions.

**Docker Compose services:**

```yaml
services:
pacs:
container_name: pacs-server
command: ["uv", "run", "python", "-m", "pacs_main"]
ports:
- "4244:4244"

mwl:
container_name: mwl-server
command: ["uv", "run", "python", "-m", "mwl_main"]
ports:
- "4243:4243"

reset:
container_name: mwl-reset
command: ["uv", "run", "python", "-m", "reset_main"]
```

Each server:
**Worklist item status transitions:**

- Runs in its own container
- Has its own Application Entity (AE)
- Uses a separate SQLite database
- Can be scaled and deployed independently
- Handles different DICOM operations (C-STORE vs C-FIND)
```
SCHEDULED ──(first C-STORE)──▶ IN PROGRESS ──(MPPS N-SET)──▶ COMPLETED
└────────(MPPS N-SET)──▶ DISCONTINUED
```
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"requests>=2.32.0",
"python-dotenv>=1.2.1",
"azure-monitor-opentelemetry>=1.8.6",
"croniter>=6.2.2",
]

[dependency-groups]
Expand Down
74 changes: 0 additions & 74 deletions scripts/python/backup_database.py

This file was deleted.

60 changes: 60 additions & 0 deletions src/backup_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Entry point for backing up PACS and MWL databases."""

import logging
import os
import sys

from dotenv import load_dotenv

from services.storage import MWLStorage, PACSStorage
from telemetry import configure_telemetry

load_dotenv()
configure_telemetry(service_name="backup")


def main():
"""
Main entry point for database backup.

Environment variables:
PACS_DB_PATH: Path to the PACS SQLite database
PACS_STORAGE_PATH: Path to PACS file storage (default: /var/lib/pacs/storage)
MWL_DB_PATH: Path to the MWL SQLite database
BACKUP_PATH: Directory for backups (default: ./backups)
"""
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
)

pacs_db_path = os.getenv("PACS_DB_PATH")
pacs_storage_path = os.getenv("PACS_STORAGE_PATH", "/var/lib/pacs/storage")
mwl_db_path = os.getenv("MWL_DB_PATH")
backup_path = os.getenv("BACKUP_PATH", "./backups")

success = True

if pacs_db_path:
try:
PACSStorage(pacs_db_path, pacs_storage_path).backup(backup_path)
except Exception as e:
logging.error(f"PACS backup failed: {e}")
success = False
else:
logging.info("PACS_DB_PATH not set, skipping PACS backup")

if mwl_db_path:
try:
MWLStorage(mwl_db_path).backup(backup_path)
except Exception as e:
logging.error(f"MWL backup failed: {e}")
success = False
else:
logging.info("MWL_DB_PATH not set, skipping MWL backup")

sys.exit(0 if success else 1)


if __name__ == "__main__":
main()
Loading
Loading