Skip to content

Quartz Local Development Setup

This guide explains how to run the SyRF Quartz service locally with automatic SQL Server provisioning and per-worktree database isolation.

Changed August 2026. Quartz used to start a separate SQL Server container per worktree. At ~1.9 GB each that made memory scale with the number of checkouts — eight running at once consumed ~16 GB. It now starts one shared container and gives each worktree its own pair of databases. See Migrating from per-worktree containers if you have old containers lying around.

Overview

The Quartz service requires SQL Server for:

  1. Quartz job store - Scheduled jobs, triggers, and scheduler state (SyRFQuartz database)
  2. MassTransit sagas - Long-running job state machines (SyRFSagas database)

For local development, you have two options:

  • Automatic local Docker container (recommended) - Zero configuration, one shared instance
  • Azure SQL Database - Shared database with schema isolation

Prerequisites

  • Docker Desktop installed and running
  • .NET 8.0 SDK

How It Works

When you run the Quartz service in Development mode, it automatically:

  1. Detects your worktree path - Uses the git repository root
  2. Generates a unique identifier - SHA256 hash of the path (8 hex chars)
  3. Ensures the shared container - Creates syrf-quartz-dev with SQL Server 2022 if it is not already running, otherwise reuses it. If several worktrees start at once, whichever loses the creation race simply reuses the winner's container.
  4. Picks a port on first creation only - Prefers 14330, falling back within 14330-14429 if that is taken. Afterwards the port is read back from Docker, so every worktree agrees on it.
  5. Creates this worktree's databases - SyRFQuartz_{hash} and SyRFSagas_{hash}
  6. Configures connection strings - Automatically injects via environment variables
  7. Displays configuration summary - Shows the port, database names and management commands

Usage

Simply run the Quartz service:

cd src/services/quartz/SyRF.Quartz
ASPNETCORE_ENVIRONMENT=Development dotnet run

On first run, you'll see:

Local development mode detected with LOCAL_AUTO - provisioning shared SQL Server container...
Creating shared SQL Server container syrf-quartz-dev on port 14330...
Waiting for SQL Server to be ready on port 14330...
Creating database SyRFQuartz_a1b2c3d4...
Creating database SyRFSagas_a1b2c3d4...
Local SQL Server container ready. Configuration summary:

╔══════════════════════════════════════════════════════════════════════╗
║           LOCAL DEVELOPMENT ENVIRONMENT CONFIGURATION                ║
╠══════════════════════════════════════════════════════════════════════╣
║  Worktree Path:     /home/chris/workspace/syrf/main                  ║
║  Worktree ID:       a1b2c3d4                                         ║
║  Container Name:    syrf-quartz-dev                                  ║
║  SQL Server:        localhost,14330                                  ║
║  SA Password:       LocalDev123!                                     ║
╠══════════════════════════════════════════════════════════════════════╣
║  SHARED: one container serves every worktree on this machine.        ║
╠══════════════════════════════════════════════════════════════════════╣
║  DATABASES (isolated per worktree)                                   ║
║  ├─ Quartz job state:  SyRFQuartz_a1b2c3d4                           ║
║  └─ MassTransit sagas: SyRFSagas_a1b2c3d4                            ║
╠══════════════════════════════════════════════════════════════════════╣
║  MANAGEMENT COMMANDS                                                 ║
║  View logs:  docker logs syrf-quartz-dev                             ║
║  Stopping or removing the container affects EVERY worktree:          ║
║    docker stop syrf-quartz-dev                                       ║
║    docker rm -f syrf-quartz-dev                                      ║
╚══════════════════════════════════════════════════════════════════════╝

Subsequent runs reuse the existing container and still show the configuration summary:

Local development mode detected with LOCAL_AUTO - provisioning shared SQL Server container...
Local SQL Server container ready. Configuration summary:
...

A worktree starting up while another already created the container simply reuses it:

Container syrf-quartz-dev was created concurrently by another worktree; reusing it.

Worktree Isolation

Every worktree shares one container but owns its own databases:

Worktree Path Container Databases
/home/chris/workspace/syrf/main syrf-quartz-dev SyRFQuartz_a1b2c3d4, SyRFSagas_a1b2c3d4
/home/chris/workspace/syrf/pr/pr2247.foo syrf-quartz-dev SyRFQuartz_e5f6a7b8, SyRFSagas_e5f6a7b8
/home/chris/workspace/syrf/pr/feature-x syrf-quartz-dev SyRFQuartz_1a2b3c4d, SyRFSagas_1a2b3c4d

This ensures:

  • Different branches/worktrees remain fully isolated at the data level
  • Schema migrations are independent per worktree
  • Memory cost stays flat as you add worktrees, instead of ~1.9 GB per checkout

The trade-off is that the SQL Server process is now shared. Stopping or removing the container affects every worktree at once, so prefer the cleanup command below, which drops only your own databases.

Container Management

View the container:

docker ps --filter "name=syrf-quartz-dev"

View container logs:

docker logs syrf-quartz-dev

List which worktrees currently have databases:

docker exec syrf-quartz-dev /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P "LocalDev123!" -C -h -1 -W \
  -Q "SET NOCOUNT ON; SELECT name FROM sys.databases WHERE name LIKE 'SyRF%'"

Drop just your worktree's databases — this is the cleanup you usually want, and it leaves other worktrees untouched. It is what LocalDevelopmentInfrastructure.CleanupLocalContainerAsync does:

# sha256sum is coreutils (Linux); shasum -a 256 is the macOS/git-bash equivalent.
sha256_hex() { if command -v sha256sum >/dev/null 2>&1; then sha256sum; else shasum -a 256; fi; }

# Lowercase the way the service's ToLowerInvariant() does, matching scripts/setup-local.sh's
# lower_invariant helper. `tr '[:upper:]' '[:lower:]'` is byte-oriented and leaves multibyte
# characters (a path containing "Ö", say) untouched, which hashes a different string than the
# service used and points these commands at databases that do not exist.
# Check the dependency up front: a missing python3 inside the pipeline would hash
# EMPTY input (always e3b0c442...) and silently target databases that don't exist.
command -v python3 >/dev/null || { echo "python3 is required for this snippet" >&2; exit 1; }
lower_invariant() { python3 -c 'import sys; sys.stdout.write(sys.stdin.read().lower())'; }

WORKTREE_HASH=$(printf '%s' "$(git rev-parse --show-toplevel)" | lower_invariant | sha256_hex | cut -c1-8)
[ ${#WORKTREE_HASH} -eq 8 ] || { echo "hash derivation failed" >&2; exit 1; }

# Mirrors LocalDevelopmentInfrastructure.DropDatabaseIfExistsAsync: SINGLE_USER is what lets
# the DROP through, so a failed DROP must put the database back to MULTI_USER — otherwise it is
# stranded in single-user mode and nothing can connect to it again.
# -b: without it sqlcmd exits 0 even when the batch fails, so the reset would look
# successful and the next run would reuse the database it claims to have dropped.
docker exec syrf-quartz-dev /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P "LocalDev123!" -C -b \
  -Q "BEGIN TRY
        IF DB_ID('SyRFQuartz_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFQuartz_$WORKTREE_HASH] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
          DROP DATABASE [SyRFQuartz_$WORKTREE_HASH];
        END
      END TRY
      BEGIN CATCH
        IF DB_ID('SyRFQuartz_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFQuartz_$WORKTREE_HASH] SET MULTI_USER;
        END;
        THROW;
      END CATCH
      BEGIN TRY
        IF DB_ID('SyRFSagas_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFSagas_$WORKTREE_HASH] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
          DROP DATABASE [SyRFSagas_$WORKTREE_HASH];
        END
      END TRY
      BEGIN CATCH
        IF DB_ID('SyRFSagas_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFSagas_$WORKTREE_HASH] SET MULTI_USER;
        END;
        THROW;
      END CATCH"

Stop or remove the container — affects EVERY worktree:

docker stop syrf-quartz-dev     # data persists
docker rm -f syrf-quartz-dev    # deletes every worktree's databases

Connect to SQL Server:

# Using sqlcmd
docker exec -it syrf-quartz-dev /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P "LocalDev123!" -C

# Or use Azure Data Studio / SSMS with:
# Server: localhost,14330
# User: sa
# Password: LocalDev123!

14330 is only the preferred port: the container keeps whichever port it was created with, so if 14330 was taken at the time it will be something else in 14331-14429. Ask Docker rather than assuming:

docker port syrf-quartz-dev 1433    # e.g. 0.0.0.0:14332 -> use localhost,14332

Migrating from per-worktree containers

If you worked on SyRF before August 2026 you will still have syrf-quartz-dev-<hash> containers, one per worktree, each holding ~1.9 GB. Once a worktree is updated past this change its container is dead weight.

scripts/setup-local.sh --prune-quartz-dbs removes them — but only the ones whose worktree no longer exists. A worktree that still exists may not have picked up the shared-container change yet and would recreate its own container anyway, so those are kept until you remove them by hand:

# Inspect first — note the trailing dash, which excludes the shared container
docker ps -a --filter 'name=syrf-quartz-dev-' --format '{{.Names}}\t{{.Size}}'

# Then remove them all, once every worktree has updated past the cutover.
# Docker's name filter matches substrings, so validate the exact legacy shape
# (8 hex chars) before removing — a container like "syrf-quartz-dev-backup"
# must never match.
# (shell loop rather than xargs -r: BSD xargs on macOS has no -r flag)
docker ps -a --format '{{.Names}}' \
  | grep -E '^syrf-quartz-dev-[0-9a-f]{8}$' \
  | while IFS= read -r name; do docker rm -f "$name"; done

Any Quartz job state in those containers is local development data and is recreated on next run, so there is nothing to preserve.

Pruning leftovers (opt-in)

One flag governs every destructive cleanup the script can do:

./scripts/setup-local.sh --prune-quartz-dbs     # or: PRUNE_QUARTZ_DBS=1 ./scripts/setup-local.sh

It reclaims two kinds of leftover whose worktree no longer exists:

  1. legacy syrf-quartz-dev-<hash> containers, and
  2. orphaned SyRFQuartz_<hash> / SyRFSagas_<hash> databases inside the shared container.

Both are opt-in for the same reason: Docker is machine-global while git worktree list is clone-local. If you have a second clone (or a bare-repo checkout) of SyRF, its worktrees are invisible from here, so its perfectly live container and databases would look orphaned and be destroyed. Only pass the flag when this clone is the only one using syrf-quartz-dev. Without it the script prints how to enable pruning and changes nothing.

Database pruning never touches anything outside the two managed prefixes, so a database of your own such as SyRFAnalytics_deadbeef is never a candidate.

Configuration

The automatic provisioning is controlled by appsettings.development.json:

{
  "ConnectionStrings": {
    "SqlConnection": "LOCAL_AUTO",
    "quartz": "LOCAL_AUTO"
  },
  "LocalDevelopment": {
    "Enabled": true,
    "UseDockerSqlServer": true
  }
}
Setting Description
LOCAL_AUTO Special marker that triggers automatic provisioning
UseDockerSqlServer Must be true for automatic containers

Using Azure SQL Instead

If you prefer to use the shared Azure SQL database (or don't have Docker):

1. Disable Local Provisioning

Override via user secrets:

cd src/services/quartz/SyRF.Quartz

# Set explicit connection strings (overrides LOCAL_AUTO)
dotnet user-secrets set "ConnectionStrings:quartz" "Server=tcp:syrf.database.windows.net,1433;Initial Catalog=syrf-quartz;..."
dotnet user-secrets set "ConnectionStrings:SqlConnection" "Server=tcp:syrf.database.windows.net,1433;Initial Catalog=syrf-sagas;..."

2. Schema Isolation

When using Azure SQL, data is isolated by SQL schema based on runtimeEnvironment:

Environment Schema Tables
Development [development] [development].QRTZ_*, [development].JobSaga
Staging [staging] [staging].QRTZ_*, [staging].JobSaga
Production [production] [production].QRTZ_*, [production].JobSaga
Preview PR [preview_2247] [preview_2247].QRTZ_* (Quartz only)

The runtimeEnvironment defaults to "development" in appsettings.json.

Warning: All local development using Azure SQL shares the same [development] schema. This means:

  • Multiple developers can conflict
  • Different worktrees share the same data

This is why local Docker containers are recommended.

Running Integration Tests

The integration tests use their own Docker container:

cd src/services/quartz/SyRF.Quartz.Tests

# Start the test SQL Server
docker-compose -f docker-compose.integration.yml up -d

# Run integration tests
dotnet test --filter "Category=Integration"

# Stop when done
docker-compose -f docker-compose.integration.yml down

Troubleshooting

Docker Not Running

Failed to provision local SQL Server container.
Ensure Docker is running or provide explicit connection strings in user secrets.

Solution: Start Docker Desktop, or provide explicit Azure SQL connection strings.

Port Conflict

The system automatically handles port conflicts by finding an alternative port:

A port is only chosen when the shared container is first created; afterwards it is read back from Docker.

Preferred port 14330 is in use. Searching for an available port...
Found available port 14331 (attempt 2)
Creating shared SQL Server container syrf-quartz-dev on port 14331...

If you need to manually resolve a port conflict:

# Find what's using the port
lsof -i :14330

# Most often it is a leftover per-worktree container from the old scheme
docker ps -a --filter 'name=syrf-quartz-dev-'

If all 100 ports in the range (14330-14429) are occupied:

Could not find an available port in range 14330-14429.
Please stop some Docker containers or free up ports.

Removing the legacy containers (see Migrating from per-worktree containers) frees these ports.

Container Won't Start

# Check container logs
docker logs syrf-quartz-dev

# Common issue: Not enough memory for SQL Server
# Solution: Increase Docker memory limit to at least 2GB

Need to Reset Database

Drop only your own databases, then restart — the service recreates them empty:

# sha256sum is coreutils (Linux); shasum -a 256 is the macOS/git-bash equivalent.
sha256_hex() { if command -v sha256sum >/dev/null 2>&1; then sha256sum; else shasum -a 256; fi; }

# Lowercase the way the service's ToLowerInvariant() does, matching scripts/setup-local.sh's
# lower_invariant helper. `tr '[:upper:]' '[:lower:]'` is byte-oriented and leaves multibyte
# characters (a path containing "Ö", say) untouched, which hashes a different string than the
# service used and points these commands at databases that do not exist.
# Check the dependency up front: a missing python3 inside the pipeline would hash
# EMPTY input (always e3b0c442...) and silently target databases that don't exist.
command -v python3 >/dev/null || { echo "python3 is required for this snippet" >&2; exit 1; }
lower_invariant() { python3 -c 'import sys; sys.stdout.write(sys.stdin.read().lower())'; }

WORKTREE_HASH=$(printf '%s' "$(git rev-parse --show-toplevel)" | lower_invariant | sha256_hex | cut -c1-8)
[ ${#WORKTREE_HASH} -eq 8 ] || { echo "hash derivation failed" >&2; exit 1; }

# Mirrors LocalDevelopmentInfrastructure.DropDatabaseIfExistsAsync: SINGLE_USER is what lets
# the DROP through, so a failed DROP must put the database back to MULTI_USER — otherwise it is
# stranded in single-user mode and nothing can connect to it again.
# -b: without it sqlcmd exits 0 even when the batch fails, so the reset would look
# successful and the next run would reuse the database it claims to have dropped.
docker exec syrf-quartz-dev /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P "LocalDev123!" -C -b \
  -Q "BEGIN TRY
        IF DB_ID('SyRFQuartz_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFQuartz_$WORKTREE_HASH] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
          DROP DATABASE [SyRFQuartz_$WORKTREE_HASH];
        END
      END TRY
      BEGIN CATCH
        IF DB_ID('SyRFQuartz_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFQuartz_$WORKTREE_HASH] SET MULTI_USER;
        END;
        THROW;
      END CATCH
      BEGIN TRY
        IF DB_ID('SyRFSagas_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFSagas_$WORKTREE_HASH] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
          DROP DATABASE [SyRFSagas_$WORKTREE_HASH];
        END
      END TRY
      BEGIN CATCH
        IF DB_ID('SyRFSagas_$WORKTREE_HASH') IS NOT NULL
        BEGIN
          ALTER DATABASE [SyRFSagas_$WORKTREE_HASH] SET MULTI_USER;
        END;
        THROW;
      END CATCH"

dotnet run

Do not docker rm -f syrf-quartz-dev to reset your own state — that wipes every worktree's databases, not just yours.

Check Connection String Being Used

Add logging to see the actual connection string:

export Logging__LogLevel__LocalDevelopmentInfrastructure=Debug
dotnet run

Architecture

Files Involved

File Purpose
LocalDevelopmentInfrastructure.cs Docker container management
Program.cs Startup integration
appsettings.development.json Development configuration

Port Selection

Preferred Port: 14330
Port Range:     14330 - 14429

Only one port is ever bound, because only one container exists. 14330 is preferred; if it is occupied the range is searched. Once the container exists the port is read back from Docker rather than recomputed, so all worktrees agree even if the first creator had to fall back.

Database Naming

SyRFQuartz_<worktreeId>
SyRFSagas_<worktreeId>

worktreeId = sha256(lowercase(worktreePath))[0:8]

The 8-hex-character id is what isolates worktrees. It is also why the name can be safely interpolated into CREATE DATABASE / DROP DATABASE, which cannot take parameters.

Container Settings

  • Name: syrf-quartz-dev (single instance, shared by all worktrees)
  • Image: mcr.microsoft.com/mssql/server:2022-latest
  • SA Password: LocalDev123!
  • Restart Policy: unless-stopped (survives Docker restarts)
  • Databases: SyRFQuartz_<hash>, SyRFSagas_<hash>, one pair per worktree