Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
32 changes: 24 additions & 8 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
services:
gateway:
pacs:
build:
context: .
dockerfile: Dockerfile
container_name: gateway-server
container_name: pacs-server
command: ["uv", "run", "python", "-m", "pacs_main"]
ports:
- "4243:4243" # MWL
- "4244:4244" # PACS
- "4244:4244"
volumes:
- pacs-storage:/var/lib/pacs/storage
- pacs-db:/var/lib/pacs
environment:
# PACS configuration
- PACS_AET=SCREENING_PACS
- PACS_PORT=4244
- PACS_STORAGE_PATH=/var/lib/pacs/storage
- PACS_DB_PATH=/var/lib/pacs/pacs.db
# MWL configuration
- LOG_LEVEL=INFO
restart: unless-stopped
healthcheck:
test: ["CMD", "sqlite3", "/var/lib/pacs/pacs.db", "SELECT 1"]
interval: 30s
timeout: 5s
retries: 3

mwl:
build:
context: .
dockerfile: Dockerfile
container_name: mwl-server
command: ["uv", "run", "python", "-m", "mwl_main"]
ports:
- "4243:4243"
volumes:
- pacs-db:/var/lib/pacs
environment:
- MWL_AET=MWL_SCP
- MWL_PORT=4243
- MWL_DB_PATH=/var/lib/pacs/worklist.db
# Logging
- LOG_LEVEL=INFO
restart: unless-stopped
healthcheck:
test: ["CMD", "sqlite3", "/var/lib/pacs/pacs.db", "SELECT 1"]
test: ["CMD", "sqlite3", "/var/lib/pacs/worklist.db", "SELECT 1"]
interval: 30s
timeout: 5s
retries: 3
Expand Down
72 changes: 0 additions & 72 deletions docs/adr/ADR-003_Multi_threaded_PACS_MWL_server.md

This file was deleted.

43 changes: 43 additions & 0 deletions docs/adr/ADR-003_Separate_containers_for_PACS_and_MWL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ADR-003: Separate containers for PACS and MWL

Date: 2026-01-08

Status: Accepted

## Context

The Gateway needs to provide DICOM services that must run together in production:

1. **PACS Server** - C-STORE operations for receiving medical images (port 4244)
2. **MWL Server** - C-FIND operations for modality worklist queries (port 4243)

Several deployment architectures were considered, including running both servers in separate threads within a single container. However, the team identified requirements that made separate containers a better fit:

1. **Independent scaling** - PACS may receive more load than MWL (or vice versa) and needs to scale independently
2. **Independent deployment** - Ability to update one service without restarting the other
3. **Operational flexibility** - Ability to restart, debug, or maintain one service independently
4. **Better alignment with container best practices** - One process per container is the standard pattern
5. **Clearer resource management** - Separate containers make it easier to monitor and allocate resources

## Decision

Run PACS and MWL servers in separate Docker containers using dedicated entry points.

**Implementation:**

Created two entry point modules:
- `pacs_main.py` - Starts only the PACS server
- `mwl_main.py` - Starts only the MWL server

## Consequences

### Positive Consequences

- **Independent scaling** - Can scale PACS and MWL based on their individual load patterns
- **Independent deployment** - Update one service without touching the other
- **Better observability** - Separate log streams and health checks for each service
- **Operational flexibility** - Can restart, debug, or maintain services independently

### Negative Consequences

- **Slightly more resource usage** - Two separate processes instead of one (minimal overhead)
39 changes: 33 additions & 6 deletions docs/mwl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The MWL server is a lightweight, production-ready DICOM worklist solution that:
- Provides scheduled procedure information via DICOM C-FIND protocol
- Stores worklist items in SQLite database
- Supports filtering by modality, date, and patient ID
- Runs alongside ([PACS Server](../pacs/README.md)) in the same Docker container
- Runs in a separate container alongside the [PACS Server](../pacs/README.md)

## Architecture

Expand Down Expand Up @@ -47,14 +47,17 @@ The MWL server is a lightweight, production-ready DICOM worklist solution that:

## Running the MWL Server

The MWL server runs in the same container as the PACS server:
The MWL server runs in a separate container:

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

# Start only MWL server
docker compose up -d mwl

# View logs
docker compose logs -f gateway
docker compose logs -f mwl

# Stop servers
docker compose down
Expand Down Expand Up @@ -136,6 +139,30 @@ uv run pytest tests/integration/test_c_find_returns_worklist_items.py -v
uv run pytest tests/integration/test_request_cfind_on_worklist.py -v
```

## Multi-server architecture
## 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.

**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"
```

Each server:

The gateway container runs both PACS and MWL servers using separate threads. See [ADR-003: Multi-threaded PACS and MWL server architecture](../adr/ADR-003_Multi_threaded_PACS_MWL_server.md) for the architectural decision and trade-offs.
- 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)
9 changes: 6 additions & 3 deletions docs/pacs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The PACS server is a lightweight, production-ready DICOM storage solution that:
- Receives medical images via DICOM C-STORE protocol
- Stores the images using hash-based directory structure
- Indexes metadata in SQLite database
- Runs alongside the [MWL Server](../mwl/README.md) in the same Docker container (see [ADR-003](../adr/ADR-003_Multi_threaded_PACS_MWL_server.md))
- Runs in a separate container alongside the [MWL Server](../mwl/README.md) (see [ADR-003](../adr/ADR-003_Separate_containers_for_PACS_and_MWL.md))

## Architecture

Expand Down Expand Up @@ -74,13 +74,16 @@ CREATE TABLE stored_instances (
## Running the PACS Server

```bash
# Start the server
# Start both PACS and MWL servers
docker compose up -d

# Start only PACS server
docker compose up -d pacs

# View logs
docker compose logs -f pacs

# Stop the server
# Stop servers
docker compose down

# Reset database and storage
Expand Down
30 changes: 30 additions & 0 deletions src/mwl_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Entry point for MWL server."""

import logging
import os

from server import MWLServer


def main():
"""Main entry point for MWL server."""
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
)

mwl_aet = os.getenv("MWL_AET", "MWL_SCP")
mwl_port = int(os.getenv("MWL_PORT", "4243"))
mwl_db_path = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db")

mwl_server = MWLServer(mwl_aet, mwl_port, mwl_db_path, block=True)

try:
mwl_server.start()
except KeyboardInterrupt:
logging.info("Received shutdown signal")
mwl_server.stop()


if __name__ == "__main__":
main()
31 changes: 31 additions & 0 deletions src/pacs_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Entry point for PACS server."""

import logging
import os

from server import PACSServer


def main():
"""Main entry point for PACS server."""
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
)

pacs_aet = os.getenv("PACS_AET", "SCREENING_PACS")
pacs_port = int(os.getenv("PACS_PORT", "4244"))
pacs_storage_path = os.getenv("PACS_STORAGE_PATH", "/var/lib/pacs/storage")
pacs_db_path = os.getenv("PACS_DB_PATH", "/var/lib/pacs/pacs.db")

pacs_server = PACSServer(pacs_aet, pacs_port, pacs_storage_path, pacs_db_path, block=True)

try:
pacs_server.start()
except KeyboardInterrupt:
logging.info("Received shutdown signal")
pacs_server.stop()


if __name__ == "__main__":
main()
47 changes: 0 additions & 47 deletions src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
"""

import logging
import os
import threading

from pynetdicom import AE, StoragePresentationContexts, evt
from pynetdicom.sop_class import ModalityWorklistInformationFind # type: ignore[attr-defined]
Expand Down Expand Up @@ -113,48 +111,3 @@ def stop(self):
if self.ae:
logger.info("Stopping MWL server")
self.ae.shutdown()


def main():
"""Main entry point for PACS and MWL servers."""
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
)

# PACS configuration
pacs_aet = os.getenv("PACS_AET", "SCREENING_PACS")
pacs_port = int(os.getenv("PACS_PORT", "4244"))
pacs_storage_path = os.getenv("PACS_STORAGE_PATH", "/var/lib/pacs/storage")
pacs_db_path = os.getenv("PACS_DB_PATH", "/var/lib/pacs/pacs.db")

# MWL configuration
mwl_aet = os.getenv("MWL_AET", "MWL_SCP")
mwl_port = int(os.getenv("MWL_PORT", "4243"))
mwl_db_path = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db")

# Create servers with block=True - each runs in its own thread so they won't block each other
pacs_server = PACSServer(pacs_aet, pacs_port, pacs_storage_path, pacs_db_path, block=True)
mwl_server = MWLServer(mwl_aet, mwl_port, mwl_db_path, block=True)

# Start servers in separate threads
pacs_thread = threading.Thread(target=pacs_server.start, name="PACSServer", daemon=True)
mwl_thread = threading.Thread(target=mwl_server.start, name="MWLServer", daemon=True)

pacs_thread.start()
mwl_thread.start()

logger.info("Both PACS and MWL servers started")

try:
# Keep main thread alive
pacs_thread.join()
mwl_thread.join()
except KeyboardInterrupt:
logger.info("Received shutdown signal")
pacs_server.stop()
mwl_server.stop()


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