fix(api): дождаться запросов перед операцией с базой
This commit is contained in:
1 parent
9264b6636a
commit
8682f2a7a5
12 files changed
+183
-5
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import { DatabaseRequestGate } from "./database-request-gate.js"
|
||||
import { installDatabaseRequestGate, registerDatabaseStreamAbort } from "../plugins/database-request-gate.js"
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(r => { resolve = r })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
test("exclusive operation waits for handler settlement and blocks new requests", async () => {
|
||||
const gate = new DatabaseRequestGate(), pending = deferred(), entered = deferred()
|
||||
const request = gate.request(async () => { entered.resolve(); await pending.promise })
|
||||
await entered.promise
|
||||
let commands = 0
|
||||
const operation = gate.exclusive(async () => { commands++ })
|
||||
await assert.rejects(gate.request(async () => {}), /повторите запрос позже/)
|
||||
await assert.rejects(gate.exclusive(async () => {}), /уже выполняется/)
|
||||
assert.equal(commands, 0)
|
||||
pending.resolve(); await request; await operation
|
||||
assert.equal(commands, 1)
|
||||
await gate.request(async () => {})
|
||||
})
|
||||
|
||||
test("drain timeout cancels command without declaring an active handler finished", async () => {
|
||||
const gate = new DatabaseRequestGate(), pending = deferred()
|
||||
const request = gate.request(() => pending.promise)
|
||||
let commands = 0
|
||||
await assert.rejects(gate.exclusive(async () => { commands++ }, 10), /операция с базой отменена/)
|
||||
assert.equal(commands, 0)
|
||||
const retry = gate.exclusive(async () => { commands++ })
|
||||
await Promise.resolve(); assert.equal(commands, 0)
|
||||
pending.resolve(); await request; await retry
|
||||
assert.equal(commands, 1)
|
||||
})
|
||||
|
||||
test("stream cancellation waits for its asynchronous cleanup", async () => {
|
||||
const gate = new DatabaseRequestGate(), aborted = deferred(), cleanup = deferred()
|
||||
const stream = gate.request(async () => { await aborted.promise; await cleanup.promise }, aborted.resolve)
|
||||
let commands = 0
|
||||
const operation = gate.exclusive(async () => { commands++ })
|
||||
await aborted.promise; assert.equal(commands, 0)
|
||||
cleanup.resolve(); await stream; await operation; assert.equal(commands, 1)
|
||||
})
|
||||
|
||||
test("Fastify gate covers GET, keeps health available and drains late stream registration", async () => {
|
||||
const app = Fastify(); installDatabaseRequestGate(app)
|
||||
const entered = deferred(), read = deferred(), cleanup = deferred(), command = deferred(), leave = deferred()
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
await app.register(async child => {
|
||||
child.get('/traffic/flow/live', async request => {
|
||||
entered.resolve(); await read.promise
|
||||
const aborted = deferred(); registerDatabaseStreamAbort(request, aborted.resolve)
|
||||
await aborted.promise; await cleanup.promise
|
||||
return { closed: true }
|
||||
})
|
||||
child.get('/servers', async () => ({ servers: [] }))
|
||||
child.post('/system/database/restore', async () => { command.resolve(); await leave.promise; return { ok: true } })
|
||||
}, { prefix: '/api' })
|
||||
try {
|
||||
const stream = app.inject('/api/traffic/flow/live'); await entered.promise
|
||||
const operation = app.inject({ method: 'POST', url: '/api/system/database/restore' })
|
||||
// inject schedules asynchronously: wait for gate to reject a subsequent GET.
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
assert.equal((await app.inject('/api/servers')).statusCode, 409)
|
||||
assert.equal((await app.inject('/health')).statusCode, 200)
|
||||
read.resolve(); cleanup.resolve(); await stream; await command.promise
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/system/database/restore' })).statusCode, 409)
|
||||
leave.resolve(); assert.equal((await operation).statusCode, 200)
|
||||
assert.equal((await app.inject('/api/servers')).statusCode, 200)
|
||||
} finally { read.resolve(); cleanup.resolve(); leave.resolve(); await app.close() }
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
function conflict(message: string): Error & { statusCode: number } {
|
||||
return Object.assign(new Error(message), { statusCode: 409 })
|
||||
}
|
||||
|
||||
/** Handler lifetime, not socket lifetime: disconnect does not finish an active write. */
|
||||
export class DatabaseRequestGate {
|
||||
private paused = false
|
||||
private active = new Map<symbol, (() => void) | undefined>()
|
||||
private idle: (() => void) | undefined
|
||||
|
||||
async request<T>(run: () => Promise<T>, cancelStream?: () => void): Promise<T> {
|
||||
if (this.paused) throw conflict("Выполняется операция с базой приложения; повторите запрос позже")
|
||||
const lease = Symbol()
|
||||
this.active.set(lease, cancelStream)
|
||||
try { return await run() }
|
||||
finally {
|
||||
this.active.delete(lease)
|
||||
if (!this.active.size) this.idle?.()
|
||||
}
|
||||
}
|
||||
|
||||
async exclusive<T>(run: () => Promise<T>, timeoutMs = 30_000): Promise<T> {
|
||||
if (this.paused) throw conflict("Операция с базой приложения уже выполняется")
|
||||
this.paused = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
for (const cancel of this.active.values()) cancel?.()
|
||||
if (this.active.size) await new Promise<void>((resolve, reject) => {
|
||||
this.idle = resolve
|
||||
timer = setTimeout(() => reject(conflict("Не удалось дождаться завершения API-запросов; операция с базой отменена")), timeoutMs)
|
||||
})
|
||||
return await run()
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
this.idle = undefined
|
||||
this.paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user