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:
- Quartz job store - Scheduled jobs, triggers, and scheduler state (
SyRFQuartzdatabase) - MassTransit sagas - Long-running job state machines (
SyRFSagasdatabase)
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
Automatic Local Development (Recommended)¶
Prerequisites¶
- Docker Desktop installed and running
- .NET 8.0 SDK
How It Works¶
When you run the Quartz service in Development mode, it automatically:
- Detects your worktree path - Uses the git repository root
- Generates a unique identifier - SHA256 hash of the path (8 hex chars)
- Ensures the shared container - Creates
syrf-quartz-devwith 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. - 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.
- Creates this worktree's databases -
SyRFQuartz_{hash}andSyRFSagas_{hash} - Configures connection strings - Automatically injects via environment variables
- Displays configuration summary - Shows the port, database names and management commands
Usage¶
Simply run the Quartz service:
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:
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:
View container logs:
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:
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:
It reclaims two kinds of leftover whose worktree no longer exists:
- legacy
syrf-quartz-dev-<hash>containers, and - 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:
Architecture¶
Files Involved¶
| File | Purpose |
|---|---|
| LocalDevelopmentInfrastructure.cs | Docker container management |
| Program.cs | Startup integration |
| appsettings.development.json | Development configuration |
Port Selection¶
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¶
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