深入 Ruby 29:配置、日志、信号与有序关闭
系列导航
导读 · 上一篇:28:Net::HTTP、Rack 与本地请求实验 · 下一篇:30:Thread、Mutex、Queue 与 GVL · 完整源码包
收到 TERM 时,正在处理的任务怎么办
进程已经接收任务 one,输入管道里还排着任务 two。此时收到 TERM,可以立即中断,也可以完成 one 后停止接收 two;两种策略都可能合理,但必须先定义,才能写出有意义的关闭测试。
本篇采用“当前任务完成,后续任务不再接收”的策略。父进程等待子进程报告 ready,提交两项任务,收到 accepted one 后才发送 TERM。随后要求事件严格为 finished、stop_accepting、closed,并验证子进程成功退出。
前置是资源管理、CLI 和 HTTP 边界。实验位于 examples/ruby/labs/29/,在 CRuby 3.4.11 的本地 POSIX 环境执行。它没有验证 Windows 信号行为、SIGKILL 清理或真正持久化队列的恢复。
配置来源先合并,再校验
Taskbook 的教学配置采用文件、环境、命令行逐级覆盖,后者优先:
1 | |
这个顺序是应用约定,Ruby 的 Hash 不会知道哪个来源更可信。merge 的后值覆盖只是实现机制;配置文档和测试必须解释为什么命令行拥有最高优先级。若颠倒两个 merge,代码仍能运行,却会让操作人员的显式参数失效。
真实环境变量最初是字符串,不能把本实验里已规范化的 Integer 值直接当作 ENV 行为。入口应先区分“没有设置”和“设置为空”,执行严格类型转换,再合并得到候选配置,最后校验范围。空字符串不应因为 truthy 就被当作合法数值。
配置文件若采用 JSON,也应应用第 26 篇的体积和字段限制。不需要运行任意 Ruby 代码的配置,不应通过 eval、load 或不可信对象反序列化实现。配置表达能力越接近代码,调用方需要承担的权限边界越大。
本篇没有把配置叠加功能装进所有 Taskbook 命令;它是一个独立生命周期实验,用来验证优先级、日志选择和关闭顺序。CLI 当前支持哪些真实参数,仍以第 27 篇帮助与子进程测试为准,不能把实验配置称为已经交付的完整配置系统。
日志采用允许记录的字段集合
把合并后的 config 整体写进日志,token 就会随之泄漏。实验选择字段白名单:
1 | |
白名单把日志需要的数据写出来,避免配置以后新增凭据字段时自动进入输出。对任意 Hash 先记录再用正则替换“看起来像密码”的字符串,容易漏掉新名称或嵌套对象。
脱敏测试应包含一个明确秘密样本,然后检查实际生成文本没有它。只检查代码里调用了 redact 方法,不知道该方法是否覆盖当前结构。日志也不应为了安全变成没有诊断价值的单词;事件类型、任务 ID、阶段和公开计数可以提供足够上下文,而不包含原始任务全文。
Ruby Logger 文档 描述日志级别与输出接口,但脱敏不是 Logger 自动赋予的属性。本例使用逐行 JSON 输出,方便父进程解析事件;如果改用 Logger,也需要自定义明确的字段与格式合同。
等级和目的地也不能混淆。DEBUG 与 INFO 表示信息级别,stdout 与 stderr 表示流。第 27 篇的 CLI stdout 已承载业务 JSON,因此附加日志应走单独通道或 stderr,避免破坏机器结果。生命周期实验是专门的子进程,stdout 在这里被定义为事件通道,两种进程的协议不同。
trap 只提交关闭请求
子进程核心逻辑如下:
1 | |
signal trap 只改变一个关闭标记。普通执行流程在任务边界检查它,决定是否继续接收。处理函数中的 sleep 代表受控的有限工作,不是生产任务算法;测试借助 accepted 事件保证 TERM 确实在当前任务执行阶段到达。
避免在 trap 中获取复杂锁、执行日志格式化或关闭一整套资源。信号可能打断持有这些资源的代码,再次进入同一依赖会制造重入与锁问题。Signal 文档 是接口和平台信号列表的依据,具体可在 trap 内安全执行的操作仍要遵守运行时限制。
一个布尔标记足以表达本实验的一次停止请求。更复杂服务可能需要自管道或其他事件通知,把请求送回事件循环;增加机制的条件是普通等待无法及时观察标记,或多个信号需要不同动作。不能仅因成熟服务使用复杂信号设施,就给这个单线程有限实验增加同样结构。
有序关闭是可观察的状态变化
ready 表示接收循环即将开始,accepted 表示某项任务归当前进程负责,finished 表示当前模拟工作完成,stop_accepting 表示循环已经退出,closed 表示该实验的收尾路径完成。每个事件都需要与实际代码位置对应,不能先打印 closed 再继续处理下一项任务。
本例没有打开额外日志文件或网络监听器,因此 closed 只表示这个子进程的教学收尾路径;最终进程退出才确认执行单元已结束。它不证明远端数据库提交、消息确认或磁盘持久化。如果未来添加真实资源,应在对应 close 或提交之后记录有根据的事件,并检查资源实际状态。
任务 two 已经写入输入管道,但从未被子进程 accepted。这区别于已接受任务被丢弃。真实队列系统还需要定义消息是在读取、确认还是提交之后转移所有权;一条“停止接收”的日志无法替代消息可重新消费的证明。
当前任务如果永远不结束,仅设置标记会让优雅关闭永远等待。生产服务需要关闭期限以及到期后的升级策略,本实验通过有限工作与父进程超时保持有界。强制终止是兜底,不应被报告为正常优雅关闭成功。
父进程用事件建立时序
验证通过 Open3 创建子进程,读取 ready 后发送两行任务,读取 accepted one 后发送 TERM:
1 | |
这种同步避免“先睡一秒,再假定任务已经开始”的不确定性。父进程明确看到当前任务已接收,才施加关闭请求。后续收集事件并要求精确顺序,同时检查 stderr 为空和子进程退出零。
外层有五秒实验超时。如果子进程仍存活,清理分支发送 KILL 并 join,防止失败场景留下后台进程。这个兜底路径不是关闭成功的证据;正常判定要求预期事件和成功退出全部满足,强制清理只能用于把失败测试收干净。
Timeout 在这里限制的是测试父进程的等待,不能由此推导业务处理中任意异步超时都是安全的。把超时异常注入正在更新共享状态的业务线程可能破坏不变量;本例的业务子进程采用协作标记,父进程只管理整个隔离实验的寿命。
与 HTTP 和文件生命周期连接
HTTP 服务关闭通常要先停止接收新连接,再处理或取消已接收请求,最后释放监听器和依赖资源。文件处理工具则可能需要完成当前输出、明确是否保留临时文件、关闭流再退出。相同的是所有权与完成条件,不同的是每种资源的具体 API。
第 28 篇的监听 socket 关闭和线程 join 可以证明本地服务实验结束;第 19 篇的 File.closed? 可以证明流关闭;本篇的 child.value 可以证明子进程结束。这些局部证据不能自动合成“所有业务副作用已经持久化”。跨边界完成需要对各方确认点逐个观测。
重复 TERM 也应有定义。当前布尔标记会保持 true,重复信号不会重复处理任务或增加新状态。更复杂系统若第二次信号触发立即退出,应显式记录这一规则并测试,避免清理代码重复执行或正在关闭的资源再次被使用。
关闭结果不要只靠最后一行日志
程序可以在打印 closed 后立即出现未处理异常,因此父进程还要等待退出状态。反过来,进程退出零却没有完成约定事件,也可能是提前返回。事件顺序和进程结果共同约束生命周期,缺少任意一项都不能宣称正常关闭。
需要持久任务时,还应检查任务所有权记录。内存事件只能说明本次进程观察到了什么,不能让已经退出的进程继续保存未完成工作。本系列后续的进程与消息选修会把确认、重试和幂等单独展开;当前两个合成任务只用于验证接收边界,不提供可靠队列服务。
运行与练习
1 | |
成功行以 PASS 29 开头;evidence/29/run.txt 保存配置、日志与 TERM 场景结果。实际判定包括 one 完成、two 未接受、子进程退出和错误流为空。Windows、不可捕获信号与真实持久队列不在这次证据范围。
练习一:把当前任务耗时延长到超过父进程期限,确认实验失败且子进程被清理,不能把兜底清理算作优雅关闭。练习二:增加一个受程序拥有的临时日志文件,在退出路径关闭,并让父进程通过明确事件和文件内容检查顺序;仍然不要在 trap 中直接格式化或写日志。
