feat(cluster): 集群化落地 —— Manager/Worker 拆分 + 归属租约 + 跨机验证(T08)
背景:把平台从「单机单进程」改造成「1 组 Manager + N 台 Worker + 共享归属状态」,
硬约束 = 全程兼容单例模式(deployMode 默认 local;生产切换前 47 一行未动)。
主要改动
1) 数据模型 v7(SQLite 与 PG 两方言同步):新增 dsh_hosts 注册表 +
dsh_instances.{host_id,epoch,heartbeat_at,lease_until};claimInstance 原子抢占
(UPDATE … WHERE host_id IS NULL OR lease_until < now)+ pinInstanceHost 钉住归属。
2) 租约与 fencing:src/supervisor/lease.ts(acquire/renew/release + stillHolder 判据 +
ttl > 2×renew 硬校验);心跳里续租,失权即向 worker 下发更高 epoch(self-fencing)。
⚠️ release 只清租约(lease_until),**保留 host_id** —— host_id 是「用户数据在哪台」的锚点。
3) Worker agent(src/worker/agent.ts,子命令 dshs worker):实例生命周期 + 文件面 /fs/*
+ 幂等键(operationId)+ 鉴权(timingSafeEqual);Worker 不写控制面数据
(apiKey/uid 由 Manager 随 launch 投递,R5 收窄)。
4) 远端 Spawner + LeasedSpawner:按 host 路由(**粘性优先**:有历史归属且那台 up 就留在原地,
否则按容量选最空的)+ 容量准入 + deployMode=cluster 装配(systemd drop-in,可回滚)。
5) bwrap 修正:**所有挂载点的中间目录统一前置 + 去重 + 由外到内**(「就近创建」会在嵌套前缀下
遮掉已绑挂载点 ⇒ bwrap: Can't chdir);且**只能用 --tmpfs**,用 --perms 会让 47 的
bwrap 0.4.0 直接拒启动(沙箱全挂)。
6) 跨机隧道 src/worker/tunnel.ts:SSH ControlMaster + 动态 -R 转发;**自愈由 agent 本地
20s 定时器驱动**(不能只放 /healthz —— 心跳本身经隧道进来,断了就没人触发它)。
7) 文件面按归属路由(RemoteUserFs):实例与文件必须落在同一台机器,否则实例看不到自己的文件。
8) 观测面:dshs doctor / dshs cluster status。
验证(本次均已实跑)
- test/lease.test.mjs:SQLite 10/10 == PG 10/10
- 组件级端到端 5 个:verify-cluster-{agent,lease,fs,migrate,live}.mjs
- 真跨机(47 Manager / 106 Worker,跨云 + 反向隧道)verify-cluster-cross.mjs 九步全绿
- 域名形态访问 verify-cluster-domain.mjs(<user>.域名 → Manager → 远端实例;越权 403)
- 冒烟 scripts/smoke-*:6/8,失败项与改动前基线完全相同(无回归)
- 生产切换与回滚剧本见 dsh-server-docs/交接单/T08-集群化落地-兼容单例模式.md §16
This commit is contained in:
1 parent
68c0a320ed
commit
c70d5d860e
47 files changed
+5367
-14
No files matched your search
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 给任意 `Spawner` 套上**归属租约**(T08 S4;设计 §1.2/§3.2/§11.5)。
|
||||
*
|
||||
* 它补上集群模式下 Manager 侧最关键的一环:**"能不能拉起"必须先问过归属**。
|
||||
* 本地模式靠"进程内 Map + 单机"天然保证单写者;多机后这个保证只能落在 DB 的原子 CAS 上
|
||||
* —— 两个 Manager 各持一把租约、抢同一个用户,就是**双写同一个 home** = 数据损坏。
|
||||
*
|
||||
* 三个动作:
|
||||
* 1. `launch` 前 **claim**:拿不到就抛 `LeaseBusyError`(**退让**,不是接管 —— 见 R9);
|
||||
* 2. 心跳里 **renewAll**:续租;同时把"我已失权"的实例用 **`/fence`** 通知 worker 停掉
|
||||
* (self-fencing 的 Manager 侧对齐,设计 §11.5);
|
||||
* 3. `stop` 时 **release**:归属交还,别人立刻可以接管(不用等 TTL)。
|
||||
*
|
||||
* ⚠️ 归属只由 Manager 写(设计 §1.3 数据分层的判据 1)。worker 侧只被通知。
|
||||
*
|
||||
* @module dshs/supervisor/leased-spawner
|
||||
*/
|
||||
import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
|
||||
import type { DbAdapter } from '../db/adapter.js'
|
||||
import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
|
||||
import { InstanceLease, type LeaseOptions } from './lease.js'
|
||||
|
||||
/** 归属被别人持有时抛出 —— 调用方应**退让**(等待/报告),**不得接管**(R9)。 */
|
||||
export class LeaseBusyError extends Error {
|
||||
readonly userId: string
|
||||
readonly holder: string | null
|
||||
readonly leaseUntil: number
|
||||
constructor(userId: string, holder: string | null, leaseUntil: number) {
|
||||
super(`instance ${userId} is held by ${holder ?? 'someone'} until ${new Date(leaseUntil).toISOString()}`)
|
||||
this.name = 'LeaseBusyError'
|
||||
this.userId = userId
|
||||
this.holder = holder
|
||||
this.leaseUntil = leaseUntil
|
||||
}
|
||||
}
|
||||
|
||||
export interface LeasedSpawnerOptions extends LeaseOptions {
|
||||
/** 本机(= 它所属的 worker)在 `dsh_hosts.id` 里的标识。 */
|
||||
hostId: string
|
||||
/** worker agent 基址。 */
|
||||
agentUrl: string
|
||||
/** 与 agent 约定的共享密钥。 */
|
||||
agentToken: string
|
||||
/** 心跳间隔(ms)。默认 = 续租间隔。 */
|
||||
heartbeatMs?: number
|
||||
/** 该 worker 的内存预算(MB);**0 = 不承载实例**(只做门户/控制,设计 §15.3)。 */
|
||||
capacityMb?: number
|
||||
/**
|
||||
* **选机**(T08 S6):返回这次要把实例放到的 `hostId`。
|
||||
*
|
||||
* ⚠️ 实现必须**先粘性、再容量**(2026-09-15 生产切换时补的设计缺口):
|
||||
* 用户的工作区是**跟机器走的**(本地盘)⇒ 把"已有历史数据在某台"的用户调度到另一台,
|
||||
* 他打开实例会看到**空工作区**。所以:有历史归属且那台还 `up` ⇒ **留在原地**;
|
||||
* 只有"从没有过归属"(新用户)才按容量挑最空的。
|
||||
* 传入 `userId` 就是为了让实现能做这件事。
|
||||
*/
|
||||
selectHost?: (userId?: string) => Promise<string | undefined>
|
||||
/** **hostId → agent 地址/密钥**(多机时 fence 要发给"实例所在的那台")。 */
|
||||
agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined
|
||||
/**
|
||||
* 启动时把自己注册进 `dsh_hosts`(幂等)。
|
||||
*
|
||||
* ⚠️ **一个 agent 只应有一条 host 记录**:`registerSelf` 只在"本 Manager 与 worker 同机"
|
||||
* (1a 形态)时该开。**专用 Manager 部署必须关掉**(`DSHS_CLUSTER_REGISTER_SELF=0`),
|
||||
* 否则会多出一条指向同一 agent 的 host 记录 ⇒ 同一个用户可能被两个 hostId 各自认领。
|
||||
*/
|
||||
registerSelf?: boolean
|
||||
/** 关闭心跳(测试里手动 tick 用)。 */
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
/** 心跳里上报给 `dsh_hosts` 的本机观测值。 */
|
||||
export interface HostObservation {
|
||||
ok: boolean
|
||||
instances: number
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
export class LeasedSpawner implements Spawner {
|
||||
private readonly lease: InstanceLease
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
private lastObservation: HostObservation | undefined
|
||||
private readonly heartbeatMs: number
|
||||
|
||||
constructor(
|
||||
private readonly inner: Spawner,
|
||||
private readonly db: DbAdapter,
|
||||
private readonly options: LeasedSpawnerOptions,
|
||||
) {
|
||||
this.lease = new InstanceLease(db, options.hostId, options)
|
||||
this.heartbeatMs = options.heartbeatMs ?? this.lease.renewMs
|
||||
}
|
||||
|
||||
get hostId(): string {
|
||||
return this.options.hostId
|
||||
}
|
||||
|
||||
/** 最近一次心跳观测(管理面/诊断用)。 */
|
||||
observation(): HostObservation | undefined {
|
||||
return this.lastObservation
|
||||
}
|
||||
|
||||
/** 本 Manager 当前持有的实例(userId → {epoch, hostId})。 */
|
||||
holdings(): ReadonlyMap<string, { epoch: number; hostId: string }> {
|
||||
return this.lease.holdings()
|
||||
}
|
||||
|
||||
/** 注册本机 + 起心跳。窗口未开时先注册一次(否则管理面看不到这台 worker)。 */
|
||||
async start(): Promise<void> {
|
||||
if (this.options.registerSelf !== false) {
|
||||
await this.db.upsertDshHost({
|
||||
id: this.options.hostId,
|
||||
endpoint: this.options.agentUrl,
|
||||
agentToken: this.options.agentToken,
|
||||
capacityMb: this.options.capacityMb ?? 0,
|
||||
})
|
||||
}
|
||||
await this.tick() // 立即一次,管理面马上能看到心跳
|
||||
if (this.options.manual === true) return
|
||||
this.timer = setInterval(() => void this.tick(), this.heartbeatMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
/** 只停**心跳定时器**(不改实例)—— 注意别和 `Spawner.stop(userId)` 混淆,故另起名。 */
|
||||
stopHeartbeat(): void {
|
||||
if (this.timer !== undefined) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次心跳:续租 → 失权则 fence → 上报本机状态。 */
|
||||
async tick(): Promise<void> {
|
||||
// ① 续租;失权的 userId 会被清出本地记录
|
||||
const lost = await this.lease.renewAll()
|
||||
for (const userId of lost) {
|
||||
// ② 我已失权 ⇒ 让 worker 停掉那个实例(下发的 epoch 取 DB 当前值 +1,确保高于它的记录)
|
||||
await this.fenceOnAgent(userId)
|
||||
}
|
||||
await this.reportHost()
|
||||
}
|
||||
|
||||
private async fenceOnAgent(userId: string): Promise<void> {
|
||||
try {
|
||||
const inst = await this.db.findUserInstance(userId, 'main')
|
||||
// 多机(T08 S6):必须发给**实例所在的那台** —— 发错 host 等于没拦(旧持有者继续写)
|
||||
const target = inst?.hostId === null || inst?.hostId === undefined
|
||||
? { agentUrl: this.options.agentUrl, token: this.options.agentToken }
|
||||
: (this.options.agentFor?.(inst.hostId) ?? { agentUrl: this.options.agentUrl, token: this.options.agentToken })
|
||||
await this.post(`/fence`, { userId, epoch: (inst?.epoch ?? 0) + 1 }, target)
|
||||
} catch {
|
||||
// 通知失败不抛:下一轮心跳会重试;即便一直失败,租约已过期 ⇒ 新持有者会重建实例
|
||||
}
|
||||
}
|
||||
|
||||
private async reportHost(): Promise<void> {
|
||||
const base = this.options.agentUrl.replace(/\/$/, '')
|
||||
try {
|
||||
const res = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(this.heartbeatMs) })
|
||||
const body = (await res.json()) as { ok?: boolean; instances?: number }
|
||||
this.lastObservation = { ok: body.ok === true, instances: body.instances ?? 0 }
|
||||
await this.db.setDshHostStatus(
|
||||
this.options.hostId,
|
||||
this.lastObservation.ok ? 'up' : 'down',
|
||||
undefined,
|
||||
Date.now(),
|
||||
)
|
||||
} catch (err) {
|
||||
this.lastObservation = { ok: false, instances: 0, lastError: err instanceof Error ? err.message : String(err) }
|
||||
await this.db.setDshHostStatus(this.options.hostId, 'down', undefined, Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
private async post(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
target?: { agentUrl: string; token: string },
|
||||
): Promise<unknown> {
|
||||
const use = target ?? { agentUrl: this.options.agentUrl, token: this.options.agentToken }
|
||||
const base = use.agentUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { [AGENT_TOKEN_HEADER]: use.token, 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`agent POST ${path} → ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Spawner 实现 ────────────────────────────────────────────────────────
|
||||
|
||||
async launch(
|
||||
userId: string,
|
||||
folder: string,
|
||||
patch?: string,
|
||||
opts?: { force?: boolean; epoch?: number; hostId?: string },
|
||||
): Promise<Instance> {
|
||||
// **先选机、再认领**(T08 S6):租约的 host_id 必须与"实例真正落在哪台"一致,
|
||||
// 否则续租/释放会指向错误的对象(多机下就是静默的脑裂入口)。
|
||||
// 显式 `opts.hostId`(迁移的目标机)优先于自动选机。
|
||||
const hostId = opts?.hostId ?? (await this.options.selectHost?.(userId)) ?? this.options.hostId
|
||||
const claim = await this.lease.acquire(userId, hostId, { folder, patch })
|
||||
if (!claim.ok) throw new LeaseBusyError(userId, claim.holder, claim.leaseUntil)
|
||||
try {
|
||||
return await this.inner.launch(userId, folder, patch, { ...opts, epoch: claim.epoch, hostId })
|
||||
} catch (err) {
|
||||
// 拉起失败就**立刻交还归属** —— 否则要白等一个 TTL 才能重试(用户侧表现为"卡住")
|
||||
await this.lease.release(userId)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async restartMain(userId: string): Promise<Instance | undefined> {
|
||||
const current = await this.inner.status(userId)
|
||||
if (current.main === undefined) return undefined
|
||||
const { folder, patch } = current.main
|
||||
await this.stop(userId)
|
||||
return this.launch(userId, folder, patch)
|
||||
}
|
||||
|
||||
/** 只重启**本 Manager 持有**的实例 —— 别人的归属不该被我重启(会与其持有者抢同一个 home)。 */
|
||||
async restartAllMains(): Promise<void> {
|
||||
for (const userId of [...this.lease.holdings().keys()]) {
|
||||
try {
|
||||
await this.restartMain(userId)
|
||||
} catch {
|
||||
// 单个失败不打断其余
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async spawnWatchdog(userId: string): Promise<Instance | undefined> {
|
||||
return this.inner.spawnWatchdog(userId)
|
||||
}
|
||||
|
||||
async status(userId: string): Promise<UserStatus> {
|
||||
return this.inner.status(userId)
|
||||
}
|
||||
|
||||
async endpointFor(userId: string): Promise<Endpoint | undefined> {
|
||||
return this.inner.endpointFor(userId)
|
||||
}
|
||||
|
||||
async stop(userId: string, hostId?: string): Promise<void> {
|
||||
// 显式 host 优先;否则用**我认领时那台**(认领记录里有)—— 别让 stop 落到别的 worker 上
|
||||
const target = hostId ?? this.lease.holdings().get(userId)?.hostId
|
||||
await this.inner.stop(userId, target)
|
||||
await this.lease.release(userId)
|
||||
}
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
this.stopHeartbeat()
|
||||
await this.inner.teardown()
|
||||
}
|
||||
|
||||
async waitForLaunchTokenForUser(userId: string, timeoutMs?: number): Promise<void> {
|
||||
return this.inner.waitForLaunchTokenForUser(userId, timeoutMs)
|
||||
}
|
||||
|
||||
async restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }> {
|
||||
return this.inner.restartAndProbe(userId, settleMs)
|
||||
}
|
||||
|
||||
touch(userId: string): void {
|
||||
this.inner.touch(userId)
|
||||
}
|
||||
|
||||
async ensureFileService(userId: string): Promise<void> {
|
||||
return this.inner.ensureFileService(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 透传可选观测面。`Spawner` 里这两个是**可选**方法 ⇒ 这里做条件委托:
|
||||
* 内层有就转发(熔断/配额是 worker 本地自管的概念,设计 §1.2),没有就回 null。
|
||||
*/
|
||||
breakerInfo(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null {
|
||||
return this.inner.breakerInfo?.(userId) ?? null
|
||||
}
|
||||
|
||||
quotaInfo(userId: string): { baseMb: number; memMb: number; heapMb: number } | null {
|
||||
return this.inner.quotaInfo?.(userId) ?? null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user