Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ StdUnit.Tags.Core 硬件无关的核心抽象 + Schemas/tagsproje
7. **解析大小写敏感度**:`EndianKinds` / `TagAccessMode`(`LittleEndian`、`R1W` …)**大小写敏感**;`isEntry` / `isEnabled` **不敏感**。测试常覆盖这些边界。
8. **`IntentCapacity` 必须 > 0**,且只能在 `RunAsync` 之前设置。
9. **`isEnabled` 只作用于自动轮询**:`TagGrpRunner` 以 `TraversalMode.RespectEnabled` 遍历(自顶向下短路——父节点未使能,其整棵子树既不读也不写,脏标记保留);手动/外部意图里的显式 `ReadAsync`/`WriteAsync` 走 `TraversalMode.IgnoreEnabled`,**不检查使能**("平时禁用 + 需要时手动点动"是刻意支持的用法)。不要把公开的读写路径也改成检查使能,也不要让驱动自行判 `IsEnabled`。`ITagGrp` 的契约里只有 `ReadAsync(TraversalMode, ct)` / `WriteAsync(TraversalMode, ct)` 这一对「真身」,1 参版本(`ReadAsync(ct)`)是 `ITagGrpExtensions` 上的扩展方法、等价于传 `IgnoreEnabled`(`TagUnion` 上同理,两个重载都在 `TagUnionExtensions`)。**实现 `ITagGrp` 时必须把 mode 透传给子节点**,否则门控会在该层断掉。`ITagCbnt` / `ITag` **不加** mode / `IsEnabled`:门控的边界是 Cbnt(其下无递归层,mode 传下去没人用),且同一个 `Tag.IsEnabled` 在「塞进 Cbnt」(整块缓存 IO,拦不住)与「直挂 `ITagGrp`」(自己做 IO)两种挂法下行为不同,语义不自洽——代价是 `DirectTag` 想在自动轮询时单独停采只能套一层 `isEnabled="false"` 的 `TagGrp`。「不可写」仍用 `access="RO"` 表达。
10. **启动是两阶段的(先算后启,事务性)**:`RunAsync` 先对全部入口算完逻辑组件匹配(`ILogicet.MatchEntry` / `Order`,纯计算),再准备 runner(建意图通道 / 订阅事件)、最后统一启动。因此:匹配/准备阶段失败 → **一个轮次都没开始**;启动阶段失败(含第三方 `StartAsync` 同步抛出)→ **取消并等待已启动的入口退出**再抛原异常。两条路径都**不能**留下 `Task` 无人等待、令牌永不取消的孤儿 runner。代价:各入口拿到的生命周期令牌是内部 linked 令牌(同一次运行内各回调仍是同一个实例,但**不保证** `==` 传给 `RunAsync` 的那一个),所以**不要用引用相等做判断**,取消时机才是契约。`MatchEntry` / `Order` 抛异常是**确定性的配置错误**:报 `TagsProjectConfigurationException`(`Location` = 入口路径,消息带逻辑组件类型),**不得**送入 `RunnerCrashed` 的重试/断开重连路径,也不得静默跳过该逻辑组件。`Enabled` 仍是每轮读取,运行期抛异常按普通轮询故障处理。

## 约定

Expand Down
49 changes: 48 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,53 @@

本文件记录**用户可见**的变化。版本号遵循[语义化版本](https://semver.org/lang/zh-CN/) `<major>.<minor>.<patch>`。

## 2.0.0-beta6 (尚未发布)
## 2.0.0-beta7(尚未发布)

> 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。

StdUnit.Tags / StdUnit.Tags.Core: **`ITagGrpRunner` 的三个回调由 `event` 改为单槽属性**
- `RunnerStarted` / `RunnerCrashed` / `TurnProcess` 由 `event` 改为 `{ get; set; }` 属性。
runner 实例从不对外暴露(由 `TagsProject` 通过 `ITagGrpRunnerFactory` 创建并持有),唯一的使用方就是 `ITagsProject`,
需要多播时由上层组合出**一个**委托再赋值。原来的 `event` 形态是**假承诺**:这些委托返回 `Task`,
而 C# 的多播只返回最后一个处理器的 `Task`,照 `await TurnProcess(...)` 写就是"只等待最后一个、其余无人观察"。
- 自定义 `ITagGrpRunner` 实现:把 `event` 声明改成属性;调用点改成"先取本地再判空"
(`var h = TurnProcess; if (h is not null) await h(grp, channel, ct);`)。订阅方写 `+=` 仍然可用(等价于读取 + 赋值)。
- 虽然对外部开发者没有影响,不过这仍属于是**公开接口形态变更**,用旧版本编译的自定义 runner 实现需要重编。

StdUnit.Tags: **`ITagsProject.RunnerStarted` / `RunnerCrashed` 修复为真正的多播(形态仍是 `event`,无 API 变更)**
- 此前它们是"转发给 runner 时抓一次委托快照",`RunAsync` 之后订阅的观察者**永远收不到**通知;且当有 2 个订阅者时 runner 只 `await` 最后一个,其余 `Task` 无人观察;运行期 `-=` 也不生效。现在转发改为"**每次通知时读当前订阅者快照**",按注册顺序串行 await 全部订阅者。
- 异常语义:任一订阅者抛异常即<b>中断本次通知</b>(排在它后面的订阅者不再被调用),异常<b>原样</b>抛出
(保留类型与堆栈,不包 `AggregateException`)。不做"收集全部异常再抛第一个"——那样等于把其余静默丢弃;
`Core` 没有 logger 可以留痕,而"谁抛谁负责、后续不再执行"更符合直觉、也更确定。
抛出的异常仍等价于该入口本轮失败(`RunnerCrashed` 抛出 = 该入口停机),所以观察者请自己 `try/catch`。
- `RunnerStarted` 的文档澄清为"**按入口**触发:每个入口各一次,崩溃恢复重连后再次触发";项目级启停仍看
`ITagsProjectCtrl.StartedOrStopped`。回调仍在轮询线程上执行,多个订阅者的耗时**相加**。

StdUnit.Tags: 在beta6.0中短暂引入了**`ITagsProject.SetTurnProcessHandler`**,它是一个单槽钩子。现在为了支持多播场景,改为订阅式 **`SubscribeTurnProcess`**
- 新签名 `IDisposable SubscribeTurnProcess(string entry, TurnProcess handler)`:每个入口可有**任意多个**订阅者,
按注册顺序**串行** await;任一订阅者抛异常即**中断**本次通知并原样抛出(= 该入口本轮失败,进入 `RunnerCrashed`
的恢复路径),与 `RunnerStarted` / `RunnerCrashed` 的语义一致。
- 返回的句柄就是退订凭据:`Dispose` 即退订,**幂等**;项目已释放后再 `Dispose` 是**无操作**(不抛)。
- 旧 `SetTurnProcessHandler(entry, handler)`(替换语义、传 `null` 清除)**已删除**。迁移:
`proj.SetTurnProcessHandler("g1", h)` → `var sub = proj.SubscribeTurnProcess("g1", h)`,
停止观测时 `sub.Dispose()`。`null` 回调现在抛 `ArgumentNullException`(退订请用句柄)。
- 顺带修复:`StdUnit.Tags.WebUi` 的 hub 不再独占该槽位——改为订阅,并在项目停止/切换、hub 自己停止时退订,
使用方自己的轮次观察者不会再被它静默覆盖。

StdUnit.Tags: **启动改为两阶段(事务性)**
- `RunAsync` 先对全部入口算完逻辑组件匹配(`ILogicet.MatchEntry` / `Order`),再建 runner 并启动轮询。启动窗口内任何失败
(`MatchEntry` / `Order` 抛异常、`IntentCapacity` 非法、runner 工厂抛异常)都不会再留下**已经跑起来但无人等待**的入口——
旧实现里这些入口的 `StartAsync` 任务会被丢弃、生命周期令牌永不取消,在后台一直轮询。
- `MatchEntry` / `Order` 抛出的异常现在报成 `TagsProjectConfigurationException`(`Location` = 入口路径,`InnerException` = 原异常,
消息带逻辑组件类型),而不是从 `RunAsync` 同步抛出的 `AggregateException`;这是确定性的配置错误,**不会**重试,也不会触发
`RunnerCrashed`。逻辑组件必须把它们实现成无副作用的纯判定:无法判断时返回 `false`。
- 启动中途失败(含第三方 `ITagGrpRunner.StartAsync` 在返回 `Task` 之前直接抛出)现在会**取消并等待已启动的入口退出**,
再把原异常抛出——不会再留下"孤儿轮询"(此前调用方连那些 `Task` 都拿不到)。
代价:入口各回调拿到的生命周期令牌不再与传给 `RunAsync` 的是同一个实例(同一次运行内仍彼此一致),
请按"取消时机"而不是引用相等来判断。


## 2.0.0-beta6

> 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。

Expand All @@ -11,6 +57,7 @@ StdUnit.Tags.WebUi: 改动点

按 `MapTagsStream()` 映射过端点的宿主无需额外代码。


## 2.0.0-beta5

> 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。
Expand Down
14 changes: 14 additions & 0 deletions docs/异常处理.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ catch (TagsProjectLoadException ex)
继承 `TagsProjectXmlException` / `TagsProjectAddressException` / `TagsProjectConfigurationException`,
或直接继承 `TagsProjectLoadException`——它们都能被 `catch (TagsProjectLoadException)` 接住,不会破坏使用方的统一处理。

#### 启动期(`RunAsync`)的"入口 × 逻辑组件"匹配也算配置判定

`ILogicet.MatchEntry` / `ILogicet.Order` 是在项目**启动时**、对每个"入口 × 逻辑组件"组合求值的**纯判定**(不是轮询工作)。
它们抛出异常意味着"这条配置无法判定",是**确定性**错误:重试、换设备、重启都不会变好。因此:

- 报 `TagsProjectConfigurationException`(`Location` = 入口路径,如 `TagGrp(g2)`,`InnerException` = 原异常,消息带逻辑组件类型名);
- **不**走 `RunnerCrashed` 的重试/退避/断开重连恢复路径,也**不**"跳过这个逻辑组件"(静默失效);
- `RunAsync` 的启动是**两阶段**的(先算完全部匹配,再建 runner、启动轮询),所以匹配失败时**没有任何入口开始轮询**,
也就不会留下"Task 无人等待、生命周期令牌永不取消"的孤儿 runner。

同样的口径适用于启动阶段的其它准备动作(建意图通道、runner 工厂):它们同样发生在任何 `StartAsync` 之前。
`Enabled` 是**每轮**读取的,它在运行期抛异常仍按普通轮询故障处理(走 `RunnerCrashed`)。
启动阶段(`ITagGrpRunner.StartAsync`)失败则**取消并等待已启动的入口退出**再抛原异常——不留孤儿轮询。

### **运行期**异常

在**运行期**,任何异常都需要被`ITagGrpRUnner`捕获进行重试——不论这是什么异常,也不论这个异常来自哪里。运行期发生的异常,是在本类库层面、还是在更底层的BCL层面,被单独捕获并识别带来的收益不大。
Expand Down
15 changes: 9 additions & 6 deletions docs/设计决策与边界/0. 设计决策与边界.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,18 @@ API 细节见教程站点(<http://tags.doc.stdunit.com>)。

- 值的**受支持的观测方式**有两种:
1. **按测点**:`ITag.OnTagRead` / `ITag.OnTagWritten`,或 Rx / R3 的 `Watch()` / `ObserveOnRead()`;
2. **按入口、按轮次**:`ITagsProject.SetTurnProcessHandler(entry, handler)`——某入口一轮结束(读取输入 + 逻辑处理之后、刷写输出之前)触发一次,适合"一次拿到该入口当前值"的观察者(如界面推送流)。回调签名是 `(entry, channel, ct)`,`ct` 是项目生命周期令牌,回调里的异步操作应透传它。
- `SetTurnProcessHandler` **每个入口最多一个回调,重复设置是替换**(传 `null` 清除)。要多个观察者就自己组装成一个 `TurnProcess`——顺序在组合处显式可见,比"按注册顺序"更可控:
2. **按入口、按轮次**:`ITagsProject.SubscribeTurnProcess(entry, handler)`(返回退订句柄)——某入口一轮结束(读取输入 + 逻辑处理之后、刷写输出之前)触发一次,适合"一次拿到该入口当前值"的观察者(如界面推送流)。回调签名是 `(entry, channel, ct)`,`ct` 是项目生命周期令牌,回调里的异步操作应透传它。
- `SubscribeTurnProcess` **每个入口可以有任意多个订阅者**:按**注册顺序串行** await(总耗时相加),任一订阅者抛异常即**中断**本次通知并**原样抛出**(= 该入口本轮失败,进入 `RunnerCrashed` 的恢复路径),排在后面的订阅者本次不再被调用。所以观察者请自己 `try/catch`,也不要指望别人替你兜住异常。
```csharp
proj.SetTurnProcessHandler("g1", async (entry, channel, ct) => { await 遥测(...); await 推送(...); });
using var 遥测 = proj.SubscribeTurnProcess("g1", 遥测回调);
using var 推送 = proj.SubscribeTurnProcess("g1", 推送回调);
```
- `SetTurnProcessHandler` **运行前、运行中都可以设置**:入口列表在 `Initialize` 之后固定,设置只是替换该入口的回调;运行期设置/清除**从下一轮开始生效**(不中断正在执行的那一轮),所以"观测端比轮询晚启动"(如 WebUi 的 hub 后启动)也能补挂上。设置无需清除:回调的作用域就是项目实例,项目停止即随之释放(项目已释放后调用抛 `ObjectDisposedException`)。但**每个入口只有一个回调**——要多个观察者就自己组装成一个 `TurnProcess`(见上),不要用反复设置/清除做"动态开关",那会与其它观察者互相覆盖;要动态开关就在自己的回调内部判断(一次集合查找)。
- 历史:该 API 早期要求"只能在运行前设置"(与 `IntentCapacity` 同构)。那条约束只服务于"回调表运行期只读、轮询线程零同步"的实现便利,代价却是**晚挂观测静默降级**(结构树在、值流不在,只有一条 Warning 且不自愈),而顺序由调用方决定、库无法保证,因此改为写时复制的回调表(读路径仍是普通字典读、零锁)。
- 启停用 `ObserveStartedOrStopped()` / `RunnerStarted`;注意 `SetTurnProcessHandler` **只覆盖"产生了轮次"**的情形,入口被禁用、项目未启动/已停止时不产生轮次,也就不会触发。
- `SubscribeTurnProcess` **运行前、运行中都可以订阅**:入口列表在 `Initialize` 之后固定;运行期订阅/退订**从下一轮开始生效**(正在跑的那一轮用的是它开始时读到的快照,"刚退订"的那一次可能仍被调用一次),所以"观测端比轮询晚启动"(如 WebUi 的 hub 后启动)也能补挂上。**退订就是 Dispose 返回的句柄**(幂等;项目已释放后 Dispose 是无操作);项目已释放后**订阅**抛 `ObjectDisposedException`。"订阅/退订"做动态开关是受支持的用法——各订阅者互不覆盖。
- 历史:该 API 早期是"每个入口一个回调、重复设置即替换",并要求"只能在运行前设置"。前者让库自带的观测端与使用方**互相静默覆盖**,后者带来**晚挂观测静默降级**(结构树在、值流不在,只有一条 Warning 且不自愈);两处现由"写时复制的订阅者数组 + 退订句柄"取代(读路径仍是普通字典读、零锁)。
- 启停用 `ObserveStartedOrStopped()` / `RunnerStarted`;注意 `SubscribeTurnProcess` **只覆盖"产生了轮次"**的情形,入口被禁用、项目未启动/已停止时不产生轮次,也就不会触发。
- `RunnerStarted` / `RunnerCrashed` 是**多播事件**:按注册顺序**串行** await 全部订阅者(总耗时相加),**运行期订阅/退订从下一次通知开始生效**;任一订阅者抛异常即**中断**本次通知并**原样抛出**(后面的订阅者本次不再被调用)——不做"收集全部再只抛第一个",那等于把其余异常静默丢弃。抛出的异常等价于该入口本轮失败(`RunnerCrashed` 抛出 = 该入口停机),所以观察者请自己 `try/catch`。`RunnerStarted` 是**按入口**触发的(每次建连、以及崩溃重连后各一次),不是"项目启动一次"——项目级启停看 `StartedOrStopped`。
- **取消不是崩溃**:`StopAsync` 导致的取消不会触发 `RunnerCrashed`,也不计入连续失败、不按重试策略退避(仍会断开该入口的全部通道)。因此进到 `RunnerCrashed` 基本等价于"真故障",可放心在那里做告警/计数。
- **启动是两阶段的(事务性)**:先为全部入口算完逻辑组件匹配(`ILogicet.MatchEntry` / `Order`,纯计算),再准备 runner、订阅事件并启动轮询。所以启动失败时不会有"跑了一半"的项目,也不会留下"Task 无人等待、令牌永不取消"的孤儿 runner——启动阶段失败会**取消并等待已启动的入口退出**再抛出(取消靠内部 linked 令牌,因此各回调的令牌实例一致,但**不保证** `==` 传给 `RunAsync` 的那个)。匹配/排序抛异常按**配置错误**处理(`TagsProjectConfigurationException`,不会重试),而不是走 `RunnerCrashed` 的恢复路径;`Enabled` 是每轮读取的,它在运行期抛异常仍算普通轮询故障。
- 事件/回调都在**轮询线程**上同步触发,拿到的已经是本轮最终结果(值 + 时间戳),不需要、也不应该再去读字段做同步。
- **不同入口的回调并行**(各入口由各自的运行器驱动);回调必须尽快返回(要干重活就只投递到自己的队列,由独立循环消费)。回调抛出的异常不会被吞掉——等价于该入口本轮失败,会进入 `RunnerCrashed` 的恢复路径(例外:停机取消导致的 `OperationCanceledException` 不算失败),观察者请自行 try/catch。
- 直接读 `ITag.Value` 只适用于"同一轮询线程内"或"接受最终一致性"的场景(例如 `Watch(startWithCurrent: true)` 拿首帧)。**界面不要轮询字段**——那是事件该干的事。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
/// 统一成本类型后,用户不必再面对 <c>NullReferenceException</c> 或字典的
/// "An item with the same key has already been added"。<br/>
/// 与 <see cref="TagsProjectValidationException"/> 的分工:前者是"构建过程中立刻发现",后者是
/// "构建之前由校验器批量扫描后一次性报告"。
/// "构建之前由校验器批量扫描后一次性报告"。<br/>
/// <br/>
/// <b>启动期</b>也用它:<see cref="ITagsProject.RunAsync"/> 为入口匹配逻辑组件时,
/// <see cref="ILogicet.MatchEntry"/> / <see cref="ILogicet.Order"/> 抛出异常意味着"这条配置无法判定"——
/// 这是确定性的配置错误,重试不会变好,因此不走 <see cref="ITagsProject.RunnerCrashed"/> 的恢复路径,
/// 而是拒绝启动(此时没有任何入口开始轮询)。
/// </summary>
public class TagsProjectConfigurationException : TagsProjectLoadException
{
Expand Down
6 changes: 6 additions & 0 deletions src/StdUnit.Tags.Core/Exceptions/TagsProjectLoadException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
/// 一个核心的决策依据是,不管异常来自哪里,以及是什么原因导致,runner 都应该会统一 <c>catch (Exception)</c> 交给重试策略与 <c>RunnerCrashed</c>——
/// 所以<b>不存在</b>一个"能兜住本库全部异常"的根类型,请按加载期/运行期分别处理。
/// </para>
/// <para>
/// <b>启动期归属于本族</b>:<see cref="ITagsProject.RunAsync"/> 在真正轮询之前为入口匹配逻辑组件
/// (<see cref="ILogicet.MatchEntry"/> / <see cref="ILogicet.Order"/>),这也是<b>配置判定</b>而不是轮询工作——
/// 它发生在任何 IO 之前、对它重试也不会变好,所以同样报 <see cref="TagsProjectConfigurationException"/>,
/// 而不是交给 <c>RunnerCrashed</c> 的重试/重连路径。
/// </para>
/// </summary>
public abstract class TagsProjectLoadException : Exception
{
Expand Down
Loading
Loading