Files
dsh_shenxian/test/lease.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

188 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* T08 S2 · 实例归属租约单测。
*
* 刻意**不 mock 时钟**:走真实 SQL(真的 PG 库)并用**极短 TTL** 制造过期,
* 这样测到的是"SQL 的原子抢占真的成立",而不是"我的假时钟算对了"。
*
* 🔴 后端只有 PostgreSQL(SQLite 后端已删除)⇒ 本套用例**只有** PG 一条通路:
* 连接串取 `DSHS_TEST_DB_URL`(统一口径;旧名 `LEASETEST_PG_URL` 兜底),
* **必须指向专用测试库**(每个用例前清空 public 下的表)。
* 未设连接串 ⇒ 全部用例**显式跳过并打印原因**(⛔ 不是静默通过)。
*
* 运行:DSHS_TEST_DB_URL=postgres://… node --test test/lease.test.mjs
*/
import { test as nodeTest } from 'node:test'
import assert from 'node:assert/strict'
import { openPgAdapter } from '../lib/db/pg.js'
import { InstanceLease, stillHolder, DEFAULT_LEASE_TTL_MS } from '../lib/supervisor/lease.js'
import { PG_SKIP_REASON, announceSkip, ensureMigrated, reportSkipSummary, resetPg } from './pg-test-db.mjs'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** 连接串:统一口径 `DSHS_TEST_DB_URL` 优先,旧名 `LEASETEST_PG_URL` 兜底。 */
const PG_URL = process.env.DSHS_TEST_DB_URL ?? process.env.LEASETEST_PG_URL
const SKIP = PG_URL === undefined || PG_URL === '' ? PG_SKIP_REASON : false
/** 缺连接串 ⇒ 每条用例都**显式跳过**(原因会出现在 `# SKIP` 行里)。 */
const test = (name, fn) => nodeTest(name, { skip: SKIP }, fn)
if (SKIP !== false) {
announceSkip('test/lease.test.mjs')
reportSkipSummary('test/lease.test.mjs')
} else {
// 迁移先跑一次 ⇒ 后面每个用例的 TRUNCATE 才有表可清。
await ensureMigrated(PG_URL)
}
/** 每个用例一个**干净的** PG 后端(共用专用测试库 ⇒ 用例前清空)。 */
async function freshDb() {
await resetPg(PG_URL)
const db = await openPgAdapter(PG_URL, 100000)
await db.createUser({
id: 'u1',
username: 'alice',
passHash: 'x',
role: 'active',
homeDir: '/tmp/u1',
})
return db
}
/** 短 TTL 的租约(ttl=60ms > 2×20ms,满足不变量)。 */
function shortLease(db, hostId) {
return new InstanceLease(db, hostId, { ttlMs: 60, renewMs: 20 })
}
test('lease: 默认时序满足 ttl > 2×renew 不变量', () => {
assert.ok(DEFAULT_LEASE_TTL_MS > 2 * 10_000, '默认 30s TTL 必须 > 2×10s 续租')
})
test('lease: 违反不变量时构造即抛(fail-loud,防抖动误判)', async () => {
const db = await freshDb()
assert.throws(() => new InstanceLease(db, 'w-a', { ttlMs: 100, renewMs: 60 }), /ttlMs/)
await db.close()
})
test('lease: 首次抢占成功,epoch 从 1 开始', async () => {
const db = await freshDb()
const a = new InstanceLease(db, 'w-a', { ttlMs: 5000, renewMs: 1000 })
const r = await a.acquire('u1')
assert.equal(r.ok, true)
assert.equal(r.epoch, 1)
assert.ok(r.leaseUntil > Date.now(), '租约应在未来')
assert.deepEqual(a.holdings().get('u1'), { epoch: 1, hostId: 'w-a' })
await db.close()
})
test('lease: 未过期时他人抢占失败 —— 单写者保证', async () => {
const db = await freshDb()
const a = new InstanceLease(db, 'w-a', { ttlMs: 5000, renewMs: 1000 })
const b = new InstanceLease(db, 'w-b', { ttlMs: 5000, renewMs: 1000 })
assert.equal((await a.acquire('u1')).ok, true)
const r = await b.acquire('u1')
assert.equal(r.ok, false, '有人在管 ⇒ 必须退让')
assert.equal(r.holder, 'w-a')
assert.equal(b.holdings().has('u1'), false, '失败不得写入本地持有记录')
await db.close()
})
test('lease: 续租必须带 epoch —— 旧持有者续租失败(fencing 生效)', async () => {
const db = await freshDb()
const a = shortLease(db, 'w-a')
assert.equal((await a.acquire('u1')).ok, true)
const staleEpoch = a.holdings().get('u1').epoch
await sleep(90) // 让租约过期
const b = shortLease(db, 'w-b')
const taken = await b.acquire('u1')
assert.equal(taken.ok, true, '过期后可被抢占')
assert.equal(taken.epoch, staleEpoch + 1, 'epoch 必须递增')
// 老持有者拿着旧 epoch 续租 ⇒ 必须失败(否则就脑裂双写了)
assert.equal(await a.renew('u1'), false)
assert.equal(a.holdings().has('u1'), false, '失权后必须清掉本地记录(供 self-fence)')
// 直接调 DB 层也一样:epoch 不匹配 → 不更新
assert.equal(await db.renewInstanceLease('u1', 'w-a', staleEpoch, 60), false)
assert.equal(await b.renew('u1'), true, '新持有者续租成功')
await db.close()
})
test('lease: 释放后归零,可再次抢占且 epoch 继续递增', async () => {
const db = await freshDb()
const a = new InstanceLease(db, 'w-a', { ttlMs: 5000, renewMs: 1000 })
await a.acquire('u1')
assert.equal(await a.release('u1'), true)
assert.equal(a.holdings().has('u1'), false)
const inst = await db.findUserInstance('u1', 'main')
assert.equal(inst.hostId, null, '释放 = 归属清空')
assert.equal(stillHolder(inst, 'w-a', 1), false)
const b = new InstanceLease(db, 'w-b', { ttlMs: 5000, renewMs: 1000 })
const r = await b.acquire('u1')
assert.equal(r.ok, true)
assert.equal(r.epoch, 2, 'epoch 单调递增(不复用)')
await db.close()
})
test('lease: release 也带 epoch 校验 —— 老持有者不能清掉新持有者的归属', async () => {
const db = await freshDb()
const a = shortLease(db, 'w-a')
await a.acquire('u1')
await sleep(90)
const b = shortLease(db, 'w-b')
await b.acquire('u1')
// a 试图释放(它本地已失权 ⇒ release 返回 false 且不动 DB)
assert.equal(await a.release('u1'), false)
const inst = await db.findUserInstance('u1', 'main')
assert.equal(inst.hostId, 'w-b', '新持有者的归属不能被误清')
await db.close()
})
test('lease: stillHolder 判据(hostId + epoch 双匹配)', async () => {
const db = await freshDb()
const a = new InstanceLease(db, 'w-a', { ttlMs: 5000, renewMs: 1000 })
await a.acquire('u1')
const inst = await db.findUserInstance('u1', 'main')
assert.equal(stillHolder(inst, 'w-a', 1), true)
assert.equal(stillHolder(inst, 'w-a', 2), false, 'epoch 不符 = 已失权')
assert.equal(stillHolder(inst, 'w-b', 1), false, '换了机器 = 已失权')
assert.equal(stillHolder(undefined, 'w-a', 1), false)
await db.close()
})
test('lease: 对账视图 —— mine() 只回本机、expiredAll() 回全局过期', async () => {
const db = await freshDb()
await db.createUser({ id: 'u2', username: 'bob', passHash: 'x', role: 'active', homeDir: '/tmp/u2' })
const a = shortLease(db, 'w-a')
const b = shortLease(db, 'w-b')
await a.acquire('u1')
await b.acquire('u2')
assert.deepEqual((await a.mine()).map((i) => i.userId), ['u1'], '一次拿回整机(本机只有 u1)')
assert.deepEqual((await b.mine()).map((i) => i.userId), ['u2'])
assert.equal((await a.expiredAll()).length, 0, '刚认领未过期')
await sleep(90)
assert.equal((await a.expiredAll()).length, 2, '过期后两台都进清单(供巡检,不自动接管)')
assert.equal((await a.expiredHere()).length, 1, 'expiredHere 只回自己名下')
await db.close()
})
test('lease: renewAll 回传失权清单(调用方据此 self-fence)', async () => {
const db = await freshDb()
const a = shortLease(db, 'w-a')
await a.acquire('u1')
assert.deepEqual(await a.renewAll(), [], '正常时无人失权')
await sleep(90)
const b = shortLease(db, 'w-b')
await b.acquire('u1') // 抢走
assert.deepEqual(await a.renewAll(), ['u1'], 'a 必须知道自己已失权')
await db.close()
})