test(sql): blackhole/timeout live-gate on a dedicated mssql container

Adds the frozen-peer / BadTimeout gate for the Sql driver — the single
highest-value integration test. Mirrors the S7 R2-01 blackhole gate:
docker pause a DEDICATED mssql mid-poll, assert the next read surfaces
BadTimeout within the client-side operationTimeout (≈3s) and well below
the server-side CommandTimeout backstop (30s), the driver degrades
(Degraded + host Stopped), and docker unpause recovers to Healthy/Running.

- Docker/docker-compose.yml: disposable `otopcua-sql-blackhole` mssql on
  :14333 (never the shared :14330 ConfigDb server) + one-shot seed.
- Docker/seed.sql: the two sample tables.
- SqlBlackholeTimeoutTests.cs: env-gated (SQL_BLACKHOLE_ENDPOINT); pauses
  ONLY the hard-coded container name; skips loudly if the endpoint is the
  shared port 14330; bounds its own wait so a wedged impl fails, not hangs.
  Offline: clean skip (no socket, no docker shell-out).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:20:24 -04:00
parent a55b7e51ca
commit 10a6a37ca5
3 changed files with 520 additions and 0 deletions
@@ -0,0 +1,68 @@
# Dedicated, DISPOSABLE SQL Server for the Sql driver's blackhole / frozen-peer live gate.
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN mssql container, `otopcua-sql-blackhole`, on host port
# 14333. The blackhole gate `docker pause`s THIS container by that hard-coded name.
# It is NOT — and must never be — the rig's shared always-on SQL Server, which is
# `10.100.0.35,14330` and hosts ConfigDb for the whole dev rig. Two independent
# guardrails keep them apart:
# 1. a distinct container_name (`otopcua-sql-blackhole`) — the pause target is a
# compile-time constant in the test, and the shared server is not named this; and
# 2. a distinct host port (14333, not 14330) — the test SKIPS LOUDLY if its
# endpoint resolves to port 14330.
# `restart: "no"` so a paused/killed container stays down rather than respawning.
#
# ── DEPLOY (from this VM; docker runs on the shared Linux host) ──────────────────
# lmxopcua-fix sync sql-blackhole # rsync this Docker/ dir → /opt/otopcua-sql-blackhole/
# lmxopcua-fix up sql-blackhole # single-service stack (+ one-shot seed), no profile arg
# `lmxopcua-fix` applies the host-side `project=lmxopcua` label; the explicit label
# below keeps the stack discoverable even when brought up with plain `docker compose`.
#
# The one-shot `mssql-seed` service applies seed.sql once the server is healthy, so
# the stack is self-seeding. The blackhole test ALSO seeds defensively on connect
# (create-if-missing, same schema), so a stack that never ran the seed still works.
name: otopcua-sql-blackhole
services:
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: otopcua-sql-blackhole
labels:
project: lmxopcua
restart: "no"
environment:
ACCEPT_EULA: "Y"
# Dev-only sandbox password for a throwaway container. Override via the shell env
# (the same value the test reads from SQL_BLACKHOLE_PASSWORD).
MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}"
MSSQL_PID: "Developer"
ports:
- "14333:1433"
healthcheck:
# -C trusts the self-signed dev cert; the login succeeding is the readiness signal.
test:
- "CMD-SHELL"
- "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -Q 'SELECT 1' || exit 1"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
# One-shot seed: waits for the server to pass its healthcheck, applies seed.sql, exits.
mssql-seed:
image: mcr.microsoft.com/mssql/server:2022-latest
labels:
project: lmxopcua
restart: "no"
depends_on:
mssql:
condition: service_healthy
environment:
MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}"
volumes:
- ./seed.sql:/seed.sql:ro
entrypoint:
- "/bin/bash"
- "-c"
- "/opt/mssql-tools18/bin/sqlcmd -C -S mssql -U sa -P \"$${MSSQL_SA_PASSWORD}\" -i /seed.sql"
@@ -0,0 +1,43 @@
-- Seed for the Sql driver's blackhole / frozen-peer live gate (dedicated container only).
--
-- Applied once by the compose `mssql-seed` one-shot against the OWNED
-- `otopcua-sql-blackhole` container. The blackhole test (SqlBlackholeTimeoutTests)
-- also creates this exact shape defensively on connect, so the two are kept in
-- parity: a Good baseline poll before the pause depends on `sqlpoll.TagValues`
-- holding the one seeded row.
--
-- Two sample tables mirror the offline SqlitePollFixture / live SqlPollServerFixture
-- shapes so the gate polls something representative, not a bespoke schema.
IF DB_ID(N'SqlBlackholeFixture') IS NULL CREATE DATABASE [SqlBlackholeFixture];
GO
USE [SqlBlackholeFixture];
GO
IF SCHEMA_ID(N'sqlpoll') IS NULL EXEC(N'CREATE SCHEMA [sqlpoll]');
GO
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
DROP TABLE IF EXISTS [sqlpoll].[TagValues];
CREATE TABLE [sqlpoll].[TagValues] (
[tag_name] nvarchar(128) NOT NULL,
[num_value] float NULL,
[sample_ts] datetime2(3) NOT NULL
);
INSERT INTO [sqlpoll].[TagValues] ([tag_name], [num_value], [sample_ts])
VALUES (N'Line1.Speed', 42.0, '2026-07-24T10:00:00'),
(N'Line1.Pressure', 3.5, '2026-07-24T10:00:01');
GO
-- Wide-row source: one row per station, several value columns.
DROP TABLE IF EXISTS [sqlpoll].[LatestStatus];
CREATE TABLE [sqlpoll].[LatestStatus] (
[station_id] int NOT NULL,
[oven_temp] float NULL,
[pressure] float NULL,
[sample_ts] datetime2(3) NOT NULL
);
INSERT INTO [sqlpoll].[LatestStatus] ([station_id], [oven_temp], [pressure], [sample_ts])
VALUES (7, 180.5, 1.2, '2026-07-24T10:00:00');
GO