# Temporal Worker — Graceful Deployment 方案

本文档描述 Temporal Worker 在 K8s 环境下零停机滚动升级的方案，与 `server/GRACEFUL-DEPLOYMENT.md` 形成姊妹文档。Worker 与 server 的区别在于：

- **不直接接收用户流量**（不挂 Service / Ingress），所以 Endpoints 摘除一项不适用；
- **持有的工作单元更长**：单个 Activity / Workflow Task 可能跑数十秒到几分钟；
- **是 Temporal task queue 的消费者**：被 SIGKILL 后，Temporal Server 会等到 ScheduleToCloseTimeout / 心跳超时才把任务返还队列，造成延迟与重复执行风险。

---

## 1. 整体时序

```
K8s 发起 Pod 删除（Deployment rolling update）
    │
    └─► 执行 lifecycle.preStop hook
         │
         └─► sleep 5s（让 readinessProbe 失败 + outbox poller 当前批次有缓冲）
              │
              └─► 发送 SIGTERM 给容器主进程（node, PID 1）
                   │
                   ├─► process.on('SIGTERM') handler 触发
                   │    ├─► worker.shutdown()        // 停止接取新 task
                   │    ├─► outboxPoller.stop()      // 不再调度下一次 poll
                   │    │     ├─► 等待当前 in-flight poll 完成
                   │    │     ├─► pg.end()
                   │    │     └─► temporalConn.close()
                   │    └─► healthServer.close()     // 停止响应 K8s probe
                   │
                   └─► worker.run() 等待所有 in-flight Activity/Workflow Task 完成后 resolve
                        └─► connection.close() → 进程退出

    ═══════════════════════════════════════════════════════════════
    terminationGracePeriodSeconds（300s）超时后 → 强制 SIGKILL
```

**关键差异：** Worker 不需要 "等 iptables 同步" 那种延迟，preStop sleep 主要起两个作用：
1. 让 `/health/ready` 提前 503，K8s probe 能察觉副本即将下线（如有上层调度策略可参考）；
2. 给 outbox poller 当前批次（已 `UPDATE ... SET processed=TRUE` 但还在 `temporalClient.workflow.start` 中的事件）多留几秒缓冲。

---

## 2. 代码改动

### 2.1 `src/index.ts` — 主流程

```ts
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT',  () => void shutdown('SIGINT'));

await worker.run();          // 阻塞直到 worker.shutdown() 被调用 + tasks drain
await connection.close();    // worker.run() resolve 之后再关连接
```

`shutdown()` 内部：
1. `worker.shutdown()` — 同步调用，使 worker 不再接取新 task；
2. `outboxPoller.stop()` — 取消下一次 setTimeout，等当前 poll 完成，关 PG / Temporal client；
3. `healthServer.close()` — 停止响应 probe。

### 2.2 `src/outbox-poller.ts` — 暴露 stop()

`startOutboxPoller()` 现在返回：

```ts
interface OutboxPollerHandle {
  stop(): Promise<void>;
}
```

`stop()` 行为：
- 设置 `stopped = true`，下一次循环不再被调度；
- 若处于 setTimeout 等待中：`clearTimeout` 立即取消；
- 若处于 in-flight poll 中：等当前 poll 完成（含本轮所有事件处理）；
- 关闭 PG client 与 Temporal Connection。

### 2.3 `src/index.ts` — Health 端点

| 端点 | 用途 | 行为 |
|------|------|------|
| `GET /health/live` | K8s livenessProbe | 进程存活始终 200 |
| `GET /health/ready` | K8s readinessProbe | `isReady && !isShuttingDown` 才 200，否则 503 |

监听端口由 `HEALTH_PORT` 环境变量控制，默认 `8080`。

### 2.4 `Dockerfile`

```dockerfile
STOPSIGNAL SIGTERM            # 与 K8s 默认信号一致
EXPOSE 8080                   # health 端口
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD wget -qO- http://127.0.0.1:8080/health/live || exit 1
CMD ["node", "dist/index.js"] # exec form：node 作为 PID 1 直接收 SIGTERM
```

---

## 3. K8s Deployment 配置

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gadmin-workflow-worker
spec:
  # ─── 滚动升级策略 ───────────────────────────────────────────
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1   # Worker 不直接接流量，可以容忍短暂少 1 个副本

  template:
    spec:
      # ─── 优雅关闭宽限期 ─────────────────────────────────────
      terminationGracePeriodSeconds: 300
      # 给 worker 足够时间完成：
      # - 当前正在执行的 Activity / Workflow Task（最长可能数分钟）
      # - outbox poller 当前批次的事件分发
      # 超过 300s 未退出将被 SIGKILL —— Temporal 端要等心跳超时才能把任务派给其他 worker

      containers:
      - name: worker
        image: <registry>/<image>:<tag>
        ports:
        - name: health
          containerPort: 8080

        # ─── preStop Hook ───────────────────────────────────
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 5"]
        # 让 readinessProbe 提前进入 503 + 给 outbox poller 当前批次缓冲

        # ─── Readiness Probe ────────────────────────────────
        # Worker 不挂 Service，readinessProbe 主要用来反映"是否健康"给运维面板
        # 也作为部署阶段判断新 Pod 是否启动成功的依据
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
          successThreshold: 1

        # ─── Liveness Probe ─────────────────────────────────
        # 检测进程僵死（如事件循环被阻塞），失败后 K8s 重启容器
        livenessProbe:
          httpGet:
            path: /health/live
            port: 8080
          initialDelaySeconds: 15
          periodSeconds: 10
          failureThreshold: 3

        # ─── 资源与环境变量（按需） ─────────────────────────
        env:
        - name: TEMPORAL_ADDRESS
          value: "<temporal-frontend>:7233"
        - name: TEMPORAL_NAMESPACE
          value: "default"
        - name: TEMPORAL_TASK_QUEUE
          value: "workflow-execution"
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: gadmin-db
              key: url
```

---

## 4. 配置说明

### 4.1 为什么 terminationGracePeriodSeconds = 300？

| 因素 | 说明 |
|------|------|
| preStop sleep | 5s |
| 当前 in-flight Activity / Workflow Task | 最长按业务实际定义，常见 30s–几分钟 |
| 当前 in-flight outbox poll 批次 | 通常 < 5s（仅启动 Temporal workflow，不等执行） |
| **总计余量** | 300s 覆盖大多数场景 |

如果你的 Activity 可能执行超过 295s（300 − 5s preStop），有两条路：
1. **加大 `terminationGracePeriodSeconds`**（例如长跑批量任务）；
2. **让 Activity 支持心跳取消**：`Context.current().heartbeat()` + `Context.current().cancellationSignal`，shutdown 时 worker 会向 Activity 发送 cancellation，business code 可以选择中断并提前返回，让 Temporal 把任务重派给其他 worker。

### 4.2 maxUnavailable 与 server 的差异

server 用 `maxUnavailable: 0`，因为它直面用户流量、Pod 数变少会立刻反映为请求失败。Worker 不接流量，副本短暂少一个不影响 Temporal 的 task 派发（Temporal 自己有 polling 模型 + 流控），所以这里放宽到 `1` 减少升级耗时。如对延迟敏感（例如就只跑 1 副本），可以收紧到 `0`。

### 4.3 Outbox poller 的语义边界

⚠️ **当前实现有一个独立于 graceful 的语义问题**：`UPDATE ... SET processed = TRUE` 与 `temporalClient.workflow.start(...)` 不在同一事务里。如果在两者之间被 SIGKILL（或 stop() 等到 grace 超时），事件已被标记为 processed 但 workflow 实例没建立，下一次 poll 不会再处理它。

graceful 关闭把这个窗口最小化（一定会等当前 poll 内的 `for (const row of rows)` 循环走完），但**没有彻底消除**。彻底解决需要把 `UPDATE` 与 `INSERT t_workflow_instance + workflow.start` 改造为：先 INSERT instance + 启 workflow，成功后再 mark outbox processed —— 这是另一个独立的修复。

---

## 5. 验证方法

### 5.1 本地验证 Shutdown

```bash
cd temporal/worker && yarn build && yarn start &
WORKER_PID=$!

# 等服务启动
sleep 3

# 验证 health
curl -s http://localhost:8080/health/live   # {"status":"ok"}
curl -s http://localhost:8080/health/ready  # {"status":"ok"}

# 发送 SIGTERM
kill -TERM $WORKER_PID

# 期望日志依次出现：
#   [Worker] SIGTERM received, beginning graceful shutdown...
#   [Worker] worker.shutdown() called, draining in-flight tasks
#   [OutboxPoller] Stopped, resources released
#   [Worker] Health server closed
#   [Worker] Temporal connection closed. Bye.
# 进程以 exit code 0 退出
```

### 5.2 K8s 环境验证

```bash
# 观察滚动升级
kubectl rollout status deployment/gadmin-workflow-worker -w

# 看 Pod 事件确认 preStop 执行
kubectl describe pod <pod-name> | grep -A5 "Events"

# 升级期间持续触发事件，验证不丢任务
# （根据业务，向能触发 outbox 的表持续写入，再去 t_workflow_instance 比对）
```

---

## 6. 信号传递链路

```
K8s kubelet
    │
    └─► containerd: 发送 STOPSIGNAL (SIGTERM)
         │
         └─► Node.js process（PID 1，因 CMD 是 exec form）
              │
              ├─► process.on('SIGTERM') handler
              │    ├─► worker.shutdown()          → worker.run() 进入 draining 状态
              │    ├─► outboxPoller.stop()        → 关 PG / Temporal client
              │    └─► healthServer.close()
              │
              └─► await worker.run() resolve → await connection.close() → 进程退出
```

---

## 7. 故障场景与应对

| 场景 | 现象 | 应对 |
|------|------|------|
| Activity 执行时间超过 grace period | 被 SIGKILL；Temporal 等心跳超时后重派 | 让 Activity 支持心跳 + cancellation；或加大 grace period |
| outbox poll 中途被 SIGKILL | 已 mark processed 但未启 workflow 的事件丢失 | 见 §4.3，需独立修复事务边界 |
| readinessProbe 失败 | 仅影响监控/调度面板；不影响 task 派发 | 检查日志、PG / Temporal 连接 |
| livenessProbe 失败 | K8s 重启容器（同样走 SIGTERM → graceful path） | 排查事件循环阻塞（同步重 IO？deadlock？） |
| `worker.shutdown()` 后迟迟不 resolve | 一定有 Activity 长时间不返回 | 最终 SIGKILL，Temporal 端等超时；治本看 §4.1 第 2 项 |
