56c99c92c3
F1: deploy/wonder-app-vd03/appsettings.Site.json (outside git, WP1.2's StartupValidator gate applies live on next install/upgrade) was missing the now-required AuditLog:SiteWriter:DatabasePath, added pointing at E:\ApiInstall\ScadaBridge\site\data\auditlog.db alongside the file's existing SiteEventLog/LocalDb paths; scanned deploy/ for other Site-role appsettings with the same gap (none) and confirmed wonder does not pin LocalDb:Replication:MaxBatchSize (F2 doesn't apply there). F2: re-pin an explicit LocalDb:Replication:MaxBatchSize=64 on docker/site-a node-a and node-b. MaxBatchBytes (2 MB default) only bounds the wire message via the 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 that split runs. Left at the 500 default, a reconnect drain of worst-case config_json rows could transiently allocate ~35 MB per read even though every wire message stayed within budget. Updated the CLAUDE.md LocalDb bullet to stop implying the row cap is fully redundant with the byte budget (topology-guide.md has no matching claim). F3: StoreAndForwardService's observer-queue onDropped callback logged a Warning per dropped item, flooding logs at sweep rate for a stuck observer with a large queue. LogObserverQueueDrop now logs once immediately on the first drop of an episode, then throttles to at most one rollup Warning per minute while drops continue, reporting the count dropped since the last log; the cumulative ObserverQueueDroppedCount counter is unaffected. Extended StoreAndForwardServiceTests with ObserverQueue_ManyDropsInOneEpisode_LogsExactlyOneWarning, which floods the bounded queue and pins exactly one drop-related Warning log for the episode via a small CapturingLogger test double. dotnet build ZB.MOM.WW.ScadaBridge.slnx: 0 warnings, 0 errors. dotnet test StoreAndForward.Tests: 134/134 passed. dotnet test Host.Tests: 490/490 passed.
143 lines
6.8 KiB
JSON
143 lines
6.8 KiB
JSON
{
|
|
"ScadaBridge": {
|
|
"Node": {
|
|
"Role": "Site",
|
|
"NodeName": "node-a",
|
|
"NodeHostname": "scadabridge-site-a-a",
|
|
"SiteId": "site-a",
|
|
"RemotingPort": 8082,
|
|
"GrpcPort": 8083,
|
|
"MetricsPort": 8084
|
|
},
|
|
"Cluster": {
|
|
"SeedNodes": [
|
|
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
|
"akka.tcp://scadabridge@scadabridge-site-a-b: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__<siteId> (docker-compose.yml).
|
|
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. 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",
|
|
// Site-a is the rig's REPLICATED pair; site-b and site-c deliberately stay
|
|
// unreplicated so the default-OFF posture is proven side-by-side on one rig.
|
|
//
|
|
// This node is the initiator: it dials the peer. Replication is still
|
|
// bidirectional - the passive node's writes flow back over the same stream -
|
|
// so only one side needs PeerAddress. Port 8083 is the existing site gRPC
|
|
// listener (h2c); the sync endpoint shares it, no new port.
|
|
//
|
|
// The ApiKey must be IDENTICAL on both nodes: the host's
|
|
// LocalDbSyncAuthInterceptor is fail-closed, so a mismatch (or a missing key)
|
|
// rejects every sync stream. A plain dev key here matches the rig's existing
|
|
// posture; PRODUCTION uses a "${secret:...}" reference resolved by the
|
|
// pre-host secret expander.
|
|
"Replication": {
|
|
"PeerAddress": "http://scadabridge-site-a-b:8083",
|
|
"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"
|
|
}
|
|
}
|
|
}
|