{ "ScadaBridge": { "Node": { "Role": "Site", "NodeName": "node-b", "NodeHostname": "scadabridge-site-a-b", "SiteId": "site-a", "RemotingPort": 8082, "GrpcPort": 8083, "MetricsPort": 8084 }, "Cluster": { "SeedNodes": [ "akka.tcp://scadabridge@scadabridge-site-a-b:8082", "akka.tcp://scadabridge@scadabridge-site-a-a:8082" ], "SplitBrainResolverStrategy": "auto-down", "StableAfter": "00:00:15", "HeartbeatInterval": "00:00:02", "FailureDetectionThreshold": "00:00:10", "MinNrOfMembers": 1, "_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.", "BootstrapGuard": { "Enabled": true, "PartnerProbeSeconds": 25, "PartnerProbeIntervalMs": 500, "ProbeConnectTimeoutMs": 1000 } }, "Database": { // Migration-only as of LocalDb Phase 2. The site config tables now live in the // consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain // a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has // started once. "SiteDbPath": "/app/data/scadabridge.db" }, "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", "WriteTimeout": "00:00:30", "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { // Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the // consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table. // SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2 // file, and is unused after that - keep it until this node has started once. "SqliteDbPath": "/app/data/store-and-forward.db" }, "Communication": { // DEV-ONLY control-plane preshared key — NOT a real secret. Must be // IDENTICAL on both nodes of the pair and match the central-side entry in // ScadaBridge__Communication__SitePsks__ (docker-compose.yml). // Production supplies this as ${secret:SB-GRPC-PSK-}. Without it the // node fails StartupValidator: the gate is fail-closed, so an unset key would // refuse every SiteStream call while the node still looked healthy. "GrpcPsk": "dev-grpc-psk-docker-site-a", "CentralGrpcEndpoints": [ "http://scadabridge-central-a:8083", "http://scadabridge-central-b:8083" ], "DeploymentTimeout": "00:02:00", "LifecycleTimeout": "00:00:30", "QueryTimeout": "00:00:30", "TransportHeartbeatInterval": "00:00:05", "TransportFailureThreshold": "00:00:15" }, "HealthMonitoring": { "ReportInterval": "00:00:30", "OfflineTimeout": "00:01:00" }, "SiteEventLog": { "RetentionDays": 30, "MaxStorageMb": 1024, "PurgeScheduleCron": "0 2 * * *" }, "Notification": {}, "Logging": { "MinimumLevel": "Information" } }, // Consolidated site database (LocalDb Phase 1): OperationTracking + site_events. // On the mounted /app/data volume so it survives container recreate - unlike the // legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths // outside the volume and were lost on every recreate. // Replication is opt-in and configured separately; absent = local-only. "LocalDb": { "Path": "/app/data/site-localdb.db", // The PASSIVE half of site-a's replicated pair: no PeerAddress, so this node's // sync initiator starts and idles while node-a dials in. Its own writes still // reach node-a - the stream is bidirectional. // // The key MUST match node-a's exactly. LocalDbSyncAuthInterceptor is // fail-closed, so a typo here does not degrade to unauthenticated replication; // it rejects every stream and the pair silently stops converging. "Replication": { "ApiKey": "dev-site-a-localdb-sync-key", // ---- Phase 2 sizing, from the Task 1 rig soak (not from the defaults) ---- // // The old MaxBatchSize = 16 pin (a hand-computed byte-budget proxy) was retired // when LocalDb 0.2.0 added MaxBatchBytes (arch-review WP3.3) - but MaxBatchSize // is NOT purely redundant with it. MaxBatchBytes bounds the WIRE message via a // per-message split in SyncSession.PumpLoopAsync; MaxBatchSize separately bounds // the DB READ page in OplogStore.ReadBatchAboveAsync / SnapshotStreamer, which // materializes the whole page into memory BEFORE the byte-budget split runs. At // the unset 500 default, a reconnect drain of 70 KB worst-case config_json rows // can transiently allocate ~35 MB per read even though every wire message stays // under the 2 MB MaxBatchBytes default (arch-review adversarial finding F2). Pin // it explicitly here to bound that transient allocation; MaxBatchBytes is left // unset (its 2 MB default) to bound the wire message. "MaxBatchSize": 64, // // Backlog caps bound the oplog while the peer is offline. Exceeding them is // NOT data loss: the oplog is pruned to the ceiling and needs_snapshot is set, // so the peer catches up by snapshot resync instead of incrementally. That // makes tighter-than-default correct here - it trades a rare full resync for a // bounded file. Sized from the soak's 0.80 sf_messages rows/sec (the only // non-zero writer measured): ~69k rows/day, so 2 days is ~138k. 250,000 leaves // room for burst without approaching the 1,000,000 default. "MaxOplogRows": 250000, "MaxOplogAge": "2.00:00:00" } }, // arch-review remediation WP1.2: the site hot-path audit writer's SQLite file has no // default path (mirrors LocalDb:Path above) and StartupValidator now requires it for // Site nodes — an unset value used to fall back to a bare "auditlog.db" resolved // relative to CWD, i.e. the container's ephemeral overlayfs, so the pending audit // backlog was silently discarded on every recreate. On the mounted /app/data volume, // same as LocalDb:Path, so it survives container recreate. "AuditLog": { "SiteWriter": { "DatabasePath": "/app/data/auditlog.db" } } }