Files
ScadaBridge/docs/test_infra/test_infra_db.md
T
Joseph Doherty da23f2dcae test(mssql): close register row 82 — throttle cross-process create+migrate, stop leaked test actors
AuditLog.Tests flaked under parallel full-solution runs. Three distinct
mechanisms, all load-sensitive and all green in isolation.

1. MsSqlMigrationFixture create+migrate saturation (the register's headline).
   Database NAMING was already isolated (a guid per fixture), but nothing
   bounded how many fixtures ran CREATE DATABASE + the ~100-migration chain at
   once. xunit parallelises processor-count collections PER PROCESS, and a
   solution-level `dotnet test` starts several MSSQL-backed processes together,
   so the dev container saw dozens of simultaneous fresh-database migrations.
   That is resource saturation, not object-level locking, and it pushed
   individual DDL statements past EF's default 30s command timeout — every
   failure had the identical fingerprint the register recorded:
   MsSqlMigrationFixture..ctor -> Migrator.MigrateAsync -> SqlException
   "Execution Timeout Expired" (error 258), which xunit surfaces as
   "Class fixture type ... threw in its constructor" against every test in the
   class.

   Fix: a machine-wide counting semaphore over the create+migrate window, built
   from sp_getapplock on master (ScadaBridgeTestMigrate/0..N-1, default 4 slots,
   SCADABRIDGE_MSSQL_TEST_MIGRATE_SLOTS to override). An xunit collection cannot
   do this — the contention is cross-PROCESS, so the arbiter has to live on the
   shared server. The gate connection is unpooled (a pooled connection defers the
   implicit release of a session-scoped app lock until reuse) and releases
   explicitly; a crashed process drops its connection and SQL releases the lock,
   so the gate cannot wedge the suite, and a deadline miss proceeds ungated
   rather than failing. Migration commands also get a 300s timeout and
   CREATE/DROP DATABASE 120s: under a bounded queue the server is busy but
   progressing, so waiting is the correct response. Plus a once-per-process
   best-effort sweep of ScadaBridgeAuditMigTest_* databases older than 2h so a
   killed run does not leave strays, and the connectivity probe now retries
   (3 attempts) — a busy server misread as an absent one would silently turn
   every MSSQL test into a Skip, which reads green and is worse than a red.

2. PartitionPurgeTests leaked AuditLogPurgeActors. Each ticks every 100ms and
   switches out the Jan-2026 partition; they were only stopped implicitly by
   TestKit Dispose -> Shutdown, which is timeout-bounded and does not fail when
   it expires. Under load a previous test's actor was still ticking against the
   shared per-class fixture DB, so PerChannelOverride_... found its seeded
   Jan-15-2026 row already gone (Expected: 1, Actual: 0).

3. NotifyDispatcherAuditTrailTests leaked NotificationOutboxActors, same shape.
   The dispatcher claims EVERY due row, not just the test's siteId, so a leaked
   actor delivered the next test's notification through its own exhausted
   QueuedOutcomeAdapter — a transient outcome, leaving the row in Retrying
   (Expected: Delivered, Actual: Retrying) and audit counts short.

   Both fixed by stopping the actor inside the test that created it
   (Watch/Stop/ExpectTerminated), removing the dependency on shutdown timing.

Evidence. Reproducer: 10 concurrent ConfigurationDatabase.Tests processes
against the one dev SQL Server — before, 10/10 processes failed (17-77 failures
each, 100% the ctor->MigrateAsync timeout); after, 3 consecutive runs 10/10
green, 395/395 each. Realistic shape (3x AuditLog.Tests + 1x
ConfigurationDatabase.Tests concurrent): first post-fix run still showed the
NotifyDispatcher leak, then 3 consecutive clean runs. Standalone: AuditLog.Tests
383/383, ConfigurationDatabase.Tests 395/395 (12s, unchanged from baseline),
SiteCallAudit.Tests 66/66. No orphan databases or stuck app locks left behind.

docs/test_infra/test_infra_db.md documents the ephemeral per-test-class
databases, the throttle and its env override, the timeouts, and the stray sweep.
2026-08-17 04:08:45 -04:00

8.4 KiB

Test Infrastructure: MS SQL 2022 Database

Overview

The test database uses Microsoft SQL Server 2022 Developer Edition running in Docker. It provides two empty databases for ScadaBridge — schema creation is handled by EF Core migrations at application startup (dev mode).

Image & Ports

  • Image: mcr.microsoft.com/mssql/server:2022-latest
  • Port: 1433
  • Edition: Developer (free, full-featured)

Credentials

Account Username Password Purpose
SA sa ScadaBridge_Dev1# Server admin (setup only)
App scadabridge_app ScadaBridge_Dev1# Application login (db_owner on both databases)

Databases

Database Purpose
ScadaBridgeConfig Configuration Database component — templates, deployments, users, audit log
ScadaBridgeMachineData Machine/operational data storage

Both databases are created by infra/mssql/setup.sql. EF Core migrations populate the ScadaBridgeConfig schema. The ScadaBridgeMachineData database is seeded with sample tables and stored procedures by infra/mssql/machinedata_seed.sql.

Ephemeral per-test-class databases

MsSqlMigrationFixture (tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/) creates a throwaway ScadaBridgeAuditMigTest_<guid> database per test class, applies the full EF migration chain to it, and drops it on teardown. Both ConfigurationDatabase.Tests and AuditLog.Tests use it — the latter by project reference, so there is one fixture implementation, not a copy. Seeing a handful of these databases come and go while tests run is normal; seeing them survive a run is not (see below).

Three behaviours are worth knowing when you look at this server during a test run:

  • Concurrent create+migrate is throttled machine-wide. Fixtures take a slot from a counting semaphore built on sp_getapplock in master (resources ScadaBridgeTestMigrate/0..N-1) and hold it across CREATE DATABASE + the migration chain. The default is 4 slots; override with SCADABRIDGE_MSSQL_TEST_MIGRATE_SLOTS. An xunit collection attribute cannot do this job — a solution-level dotnet test runs several MSSQL-backed test processes at once, so the throttle has to live on the shared server. Without it, dozens of simultaneous fresh-database migrations saturated the container and individual DDL statements blew past EF's default 30-second command timeout, failing whole test classes at fixture construction (deferred-work register row 82).
  • Migration commands use a 300s timeout, and CREATE/DROP DATABASE use 120s. Under a bounded queue the server is busy but progressing, so the correct response to a slow statement is to wait.
  • Strays are swept. Each test process drops, once at start and best-effort, any ScadaBridgeAuditMigTest_* database older than 2 hours, so a run killed before teardown does not leave databases accumulating. To clear them by hand:
docker exec -i scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
  -Q "SELECT name, create_date FROM sys.databases WHERE name LIKE 'ScadaBridgeAuditMigTest_%'"

Overriding the server the fixture targets (CI, a non-default port) is done with SCADABRIDGE_MSSQL_TEST_CONN. When no server answers, the fixture reports Available = false and the tests report as Skipped, not passed.

Data Persistence

SQL data is stored in the named Docker volume scadabridge-mssql-data. Data survives container restarts and docker compose down. To reset the database completely:

docker compose down -v
docker compose up -d
# Re-run setup.sql after the container starts

First-Time Setup

After the first docker compose up -d, run the setup script:

docker exec -i scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
  -i /docker-entrypoint-initdb.d/setup.sql

This creates the databases and the scadabridge_app login. Then seed the Machine Data database with sample tables, stored procedures, and data:

docker exec -i scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
  -i /docker-entrypoint-initdb.d/machinedata_seed.sql

You only need to run these once (or again after deleting the volume).

Connection Strings

For appsettings.Development.json:

Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true
Server=localhost,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true

Verification

  1. Check the container is running:
docker ps --filter name=scadabridge-mssql
  1. Query using sqlcmd inside the container:
docker exec -it scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
  -Q "SELECT name FROM sys.databases"
  1. Verify the app login:
docker exec -it scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U scadabridge_app -P 'ScadaBridge_Dev1#' -C \
  -d ScadaBridgeConfig \
  -Q "SELECT DB_NAME()"

CLI Tool

The infra/tools/mssql_tool.py script provides a convenient CLI for interacting with the SQL Server.

Install dependencies (one-time):

pip install -r infra/tools/requirements.txt

Commands:

# Check connectivity and verify expected databases exist
python infra/tools/mssql_tool.py check

# Run the first-time setup script (uses autocommit mode for CREATE DATABASE)
python infra/tools/mssql_tool.py setup --script infra/mssql/setup.sql

# List tables in a database
python infra/tools/mssql_tool.py tables --database ScadaBridgeConfig

# Run an ad-hoc query
python infra/tools/mssql_tool.py query --database ScadaBridgeConfig --sql "SELECT name FROM sys.tables"

Use --host, --port, --user, --password to override defaults (localhost:1433, sa, ScadaBridge_Dev1#). Run with --help for full usage.

Machine Data Tables & Stored Procedures

The machinedata_seed.sql script creates the following in ScadaBridgeMachineData:

Tables:

Table Description Sample Data
TagHistory Time-series tag values from OPC UA / custom protocols Pressure, flow, level, temperature, speed readings for SiteA/SiteB
ProductionCounts Shift/line production totals (good, reject, efficiency) 3 days of 2-shift data across 2 sites
EquipmentEvents State changes, faults, maintenance, alarm events Pump faults, belt inspections, batch starts
BatchRecords Production batch tracking (start, complete, abort) 5 batches including one in-progress
AlarmHistory Historical alarm activations, acks, and clears Active, acknowledged, and cleared alarms

Stored Procedures:

Procedure Description
usp_GetTagHistory Get tag values for a tag path within a date range
usp_GetProductionSummary Aggregate production by line over a date range
usp_InsertBatchRecord Insert a new batch (for CachedWrite testing)
usp_CompleteBatch Complete or abort a batch
usp_GetEquipmentEvents Get recent equipment events with optional filters
usp_GetActiveAlarms Get active/acknowledged alarms by severity

Relevance to ScadaBridge Components

  • Configuration Database — primary consumer; EF Core context targets ScadaBridgeConfig.
  • Deployment Manager — reads/writes deployment records in ScadaBridgeConfig.
  • Template Engine — reads/writes template definitions in ScadaBridgeConfig.
  • Security & Auth — user/role data stored in ScadaBridgeConfig.
  • External System Gateway — scripts use Database.Connection("machineDataConnection") to query ScadaBridgeMachineData tables and stored procedures.
  • Site Runtime — scripts call stored procedures via Database.Connection() and Database.CachedWrite() for batch recording and data queries.
  • Inbound API — methods can query machine data via named database connections.

Notes

  • The sa password must meet SQL Server complexity requirements (uppercase, lowercase, digit, special character, 8+ characters).
  • If the container fails to start, check Docker has at least 2GB RAM allocated (SQL Server minimum requirement).
  • The setup script is idempotent — safe to run multiple times.