Files
dsh_shenxian/test/lease.test.mjs
T

183 lines
7.2 KiB
JavaScript
Raw Normal View History

/**
* T08 S2 · 实例归属租约单测。
*
* 刻意**不 mock 时钟**:走真实 SQL(SqliteAdapter(':memory:'))并用**极短 TTL** 制造过期,
* 这样测到的是"SQL 的原子抢占真的成立",而不是"我的假时钟算对了"。
*
* 两个后端都跑(同 T08 S1 的做法):默认 SQLite(内存库,每用例一份);
* 设 `LEASETEST_PG_URL=postgres://…` 时改跑 PG —— 用来验证两套实现语义一致。
* 运行:node --test test/lease.test.mjs
* LEASETEST_PG_URL=postgres://dshs:[email protected]:15432/dshs_smoke node --test test/lease.test.mjs
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import pg from 'pg'
import { SqliteAdapter } from '../lib/db/sqlite.js'
import { openPgAdapter } from '../lib/db/pg.js'
import { InstanceLease, stillHolder, DEFAULT_LEASE_TTL_MS } from '../lib/supervisor/lease.js'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const PG_URL = process.env.LEASETEST_PG_URL
/** PG 侧:每个用例前清空三张表(该库专供本测试,清空是安全的)。 */
async function resetPg() {
const client = new pg.Client({ connectionString: PG_URL })
await client.connect()
await client.query('DELETE FROM dsh_instances')
await client.query('DELETE FROM dsh_hosts')
await client.query('DELETE FROM users')
await client.end()
}
/** 每个用例一个独立后端(SQLite = 新内存库;PG = 清空后的专用库)。 */
async function freshDb() {
const db = PG_URL === undefined ? new SqliteAdapter(':memory:', 100000) : await openPgAdapter(PG_URL, 100000)
if (PG_URL !== undefined) await resetPg()
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()
})