diff --git a/docker-env2/site-x-node-a/appsettings.Site.json b/docker-env2/site-x-node-a/appsettings.Site.json index 0cc7f03d..5c2ada30 100644 --- a/docker-env2/site-x-node-a/appsettings.Site.json +++ b/docker-env2/site-x-node-a/appsettings.Site.json @@ -25,7 +25,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker-env2/site-x-node-b/appsettings.Site.json b/docker-env2/site-x-node-b/appsettings.Site.json index a28728cf..b53344a1 100644 --- a/docker-env2/site-x-node-b/appsettings.Site.json +++ b/docker-env2/site-x-node-b/appsettings.Site.json @@ -25,7 +25,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json index 44f588fe..905ff385 100644 --- a/docker/site-a-node-a/appsettings.Site.json +++ b/docker/site-a-node-a/appsettings.Site.json @@ -26,7 +26,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json index 7a3a9dec..f1db4f2a 100644 --- a/docker/site-a-node-b/appsettings.Site.json +++ b/docker/site-a-node-b/appsettings.Site.json @@ -26,7 +26,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker/site-b-node-a/appsettings.Site.json b/docker/site-b-node-a/appsettings.Site.json index 88a7c8af..93018c13 100644 --- a/docker/site-b-node-a/appsettings.Site.json +++ b/docker/site-b-node-a/appsettings.Site.json @@ -26,7 +26,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker/site-b-node-b/appsettings.Site.json b/docker/site-b-node-b/appsettings.Site.json index 9334191d..755b0b0a 100644 --- a/docker/site-b-node-b/appsettings.Site.json +++ b/docker/site-b-node-b/appsettings.Site.json @@ -26,7 +26,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker/site-c-node-a/appsettings.Site.json b/docker/site-c-node-a/appsettings.Site.json index 33b17691..40d2269f 100644 --- a/docker/site-c-node-a/appsettings.Site.json +++ b/docker/site-c-node-a/appsettings.Site.json @@ -26,7 +26,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docker/site-c-node-b/appsettings.Site.json b/docker/site-c-node-b/appsettings.Site.json index 5a69e034..93a6b292 100644 --- a/docker/site-c-node-b/appsettings.Site.json +++ b/docker/site-c-node-b/appsettings.Site.json @@ -26,7 +26,8 @@ "DataConnection": { "ReconnectInterval": "00:00:05", "TagResolutionRetryInterval": "00:00:10", - "WriteTimeout": "00:00:30" + "WriteTimeout": "00:00:30", + "SeedReadTimeout": "00:00:30" }, "StoreAndForward": { "SqliteDbPath": "/app/data/store-and-forward.db", diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index bfedf58f..b5a7761f 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -174,6 +174,7 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per- | `ReconnectInterval` | 5s | Fixed interval between reconnection attempts | | `TagResolutionRetryInterval` | 10s | Retry interval for unresolved tag paths | | `WriteTimeout` | 30s | Timeout for write operations | +| `SeedReadTimeout` | 30s | Per-tag timeout for seed reads on the initial-subscribe (and reconnect re-seed) path. A hung device read is treated as a failed seed: retried up to `SeedReadMaxAttempts`, then the tag stays Uncertain until a change notification arrives. | ## Subscription Management diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index 4432cbc3..0cdd89ef 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -780,9 +780,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers { foreach (var tagPath in pending.ToList()) { + // DataConnectionLayer-027: bound each per-tag read with SeedReadTimeout so a + // hung device read cannot delay SubscribeCompleted/the ack indefinitely. + // On timeout the catch block treats it identically to any other failed seed + // read — the tag stays in pending, is retried up to SeedReadMaxAttempts, and + // left Uncertain if still empty after the budget. Same CancellationTokenSource + // mechanism used by HandleWrite for WriteTimeout (DataConnectionLayer-005). + using var cts = new CancellationTokenSource(_options.SeedReadTimeout); try { - var readResult = await adapter.ReadAsync(tagPath); + var readResult = await adapter.ReadAsync(tagPath, cts.Token); if (readResult.Success && readResult.Value is { Value: not null } value) { seedValues.Add(new SeededValue(tagPath, value)); @@ -792,6 +799,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers catch { // Best-effort read — retried below, or logged once the budget is spent. + // Includes OperationCanceledException on SeedReadTimeout expiry. } } diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs index d1558375..b4da3d55 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs @@ -14,6 +14,16 @@ public class DataConnectionOptions /// Timeout for synchronous write operations to devices. public TimeSpan WriteTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// + /// DataConnectionLayer-027: per-tag timeout applied to each + /// call on the initial-subscribe seed path (and reconnect re-seed). A hung device read would + /// otherwise delay SubscribeCompleted and its acknowledgement indefinitely. On timeout + /// the tag is treated the same as any other failed seed read — it stays in the pending set and + /// is retried up to , then left Uncertain until a change + /// notification arrives. + /// + public TimeSpan SeedReadTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// /// Minimum time a connection must stay up before it is considered stable. /// If a connection drops before this threshold, it counts as an unstable