fix(api): graceful shutdown, единый обработчик ошибок и корректные 404
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0948455d15
commit
140ffdb7b1
10 files changed
+335
-9
No files matched your search
+49
-9
@@ -1,4 +1,4 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyError, FastifyInstance } from 'fastify'
|
||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import helmet from '@fastify/helmet'
|
import helmet from '@fastify/helmet'
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
allPermissionKeys,
|
allPermissionKeys,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import {
|
import {
|
||||||
|
closeDb,
|
||||||
createDb,
|
createDb,
|
||||||
getUserApps,
|
getUserApps,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
@@ -35,6 +36,9 @@ import { webauthnRoutes } from './routes/webauthn.js'
|
|||||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||||
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
||||||
|
|
||||||
|
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
|
||||||
|
const API_PREFIXES = ['/api', '/oauth', '/.well-known']
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
config: AppConfig
|
config: AppConfig
|
||||||
@@ -97,11 +101,42 @@ export async function buildApp(opts: {
|
|||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: config.logLevel },
|
logger: { level: config.logLevel },
|
||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
|
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
||||||
|
keepAliveTimeout: 65_000,
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 30_000,
|
||||||
|
bodyLimit: 1_048_576,
|
||||||
})
|
})
|
||||||
|
|
||||||
app.decorate('config', config)
|
app.decorate('config', config)
|
||||||
app.decorate('db', db)
|
app.decorate('db', db)
|
||||||
app.decorate('sqlite', sqlite)
|
app.decorate('sqlite', sqlite)
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
closeDb(sqlite)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
|
||||||
|
const status =
|
||||||
|
err.name === 'ZodError'
|
||||||
|
? 400
|
||||||
|
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
|
||||||
|
? (err as FastifyError).statusCode!
|
||||||
|
: 500
|
||||||
|
if (status >= 500) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.status(500).send({
|
||||||
|
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const code =
|
||||||
|
status === 429
|
||||||
|
? 'RATE_LIMITED'
|
||||||
|
: status === 400
|
||||||
|
? 'BAD_REQUEST'
|
||||||
|
: ((err as FastifyError).code ?? 'ERROR')
|
||||||
|
return reply.status(status).send({ error: { code, message: err.message } })
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
await app.register(sensible)
|
await app.register(sensible)
|
||||||
await app.register(formbody)
|
await app.register(formbody)
|
||||||
@@ -147,15 +182,20 @@ export async function buildApp(opts: {
|
|||||||
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
|
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
|
||||||
preCompressed: true,
|
preCompressed: true,
|
||||||
})
|
})
|
||||||
app.setNotFoundHandler((req, reply) => {
|
|
||||||
if (req.method === 'GET' && !req.url.startsWith('/api')) {
|
|
||||||
return reply.sendFile('index.html')
|
|
||||||
}
|
|
||||||
return reply
|
|
||||||
.status(404)
|
|
||||||
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
if (
|
||||||
|
spa &&
|
||||||
|
req.method === 'GET' &&
|
||||||
|
!API_PREFIXES.some((p) => req.url.startsWith(p))
|
||||||
|
) {
|
||||||
|
return reply.sendFile('index.html')
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||||
|
})
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,8 @@ export const configSchema = z.object({
|
|||||||
serverPort: z.coerce.number().int().positive().default(8080),
|
serverPort: z.coerce.number().int().positive().default(8080),
|
||||||
staticDir: z.string().optional(),
|
staticDir: z.string().optional(),
|
||||||
logLevel: z.string().default('info'),
|
logLevel: z.string().default('info'),
|
||||||
|
/** Максимум на graceful shutdown, затем принудительный выход. */
|
||||||
|
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
|
||||||
isProd: z.boolean(),
|
isProd: z.boolean(),
|
||||||
auditIngestSecret: z.string().min(8).optional(),
|
auditIngestSecret: z.string().min(8).optional(),
|
||||||
webauthnRpID: z.string().min(1),
|
webauthnRpID: z.string().min(1),
|
||||||
@@ -56,6 +58,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
serverPort: env.SERVER_PORT ?? 8080,
|
serverPort: env.SERVER_PORT ?? 8080,
|
||||||
staticDir: env.STATIC_DIR || undefined,
|
staticDir: env.STATIC_DIR || undefined,
|
||||||
logLevel: env.LOG_LEVEL ?? 'info',
|
logLevel: env.LOG_LEVEL ?? 'info',
|
||||||
|
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
|
||||||
isProd:
|
isProd:
|
||||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||||
isProd,
|
isProd,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export interface ShutdownOptions {
|
||||||
|
timeoutMs: number
|
||||||
|
exit?: (code: number) => void
|
||||||
|
signals?: NodeJS.Signals[]
|
||||||
|
proc?: Pick<NodeJS.Process, 'on' | 'off'>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
|
||||||
|
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
|
||||||
|
* Возвращает функцию отписки и ручной триггер (для тестов).
|
||||||
|
*/
|
||||||
|
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
|
||||||
|
const exit = opts.exit ?? ((code: number) => process.exit(code))
|
||||||
|
const proc = opts.proc ?? process
|
||||||
|
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
|
||||||
|
let closing: Promise<void> | null = null
|
||||||
|
|
||||||
|
const shutdown = (reason: string, code = 0): Promise<void> => {
|
||||||
|
if (closing) {
|
||||||
|
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
|
||||||
|
exit(1)
|
||||||
|
return closing
|
||||||
|
}
|
||||||
|
app.log.info(`shutdown: ${reason}`)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
app.log.error('shutdown: timeout, forcing exit')
|
||||||
|
exit(1)
|
||||||
|
}, opts.timeoutMs)
|
||||||
|
timer.unref()
|
||||||
|
closing = app
|
||||||
|
.close()
|
||||||
|
.then(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
exit(code)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
app.log.error(err, 'shutdown: close failed')
|
||||||
|
exit(1)
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = signals.map((sig) => {
|
||||||
|
const h = () => void shutdown(sig)
|
||||||
|
proc.on(sig, h)
|
||||||
|
return [sig, h] as const
|
||||||
|
})
|
||||||
|
const onFatal = (err: unknown) => {
|
||||||
|
app.log.error(err, 'fatal error')
|
||||||
|
void shutdown('fatal', 1)
|
||||||
|
}
|
||||||
|
proc.on('unhandledRejection', onFatal)
|
||||||
|
proc.on('uncaughtException', onFatal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
shutdown,
|
||||||
|
dispose() {
|
||||||
|
for (const [sig, h] of handlers) proc.off(sig, h)
|
||||||
|
proc.off('unhandledRejection', onFatal)
|
||||||
|
proc.off('uncaughtException', onFatal)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { buildApp } from './app.js'
|
import { buildApp } from './app.js'
|
||||||
import { loadConfig } from './config.js'
|
import { loadConfig } from './config.js'
|
||||||
|
import { registerShutdown } from './lib/shutdown.js'
|
||||||
|
|
||||||
for (const path of [
|
for (const path of [
|
||||||
resolve(import.meta.dirname, '../../../.env'),
|
resolve(import.meta.dirname, '../../../.env'),
|
||||||
@@ -31,6 +32,8 @@ for (const path of [
|
|||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
const app = await buildApp({ config })
|
const app = await buildApp({ config })
|
||||||
|
|
||||||
|
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||||
app.log.info(`auth-portal listening on ${config.serverPort}`)
|
app.log.info(`auth-portal listening on ${config.serverPort}`)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handler', () => {
|
||||||
|
it('необработанная ошибка → 500 без деталей', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.get('/__boom', async () => {
|
||||||
|
throw new Error('secret internals')
|
||||||
|
})
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/__boom' })
|
||||||
|
expect(res.statusCode).toBe(500)
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||||
|
})
|
||||||
|
expect(res.body).not.toContain('secret internals')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ZodError → 400 BAD_REQUEST', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/__zod' })
|
||||||
|
expect(res.statusCode).toBe(400)
|
||||||
|
expect(res.json().error.code).toBe('BAD_REQUEST')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ошибка с statusCode 4xx сохраняет статус', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.get('/__teapot', async () => {
|
||||||
|
throw Object.assign(new Error('nope'), { statusCode: 418 })
|
||||||
|
})
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/__teapot' })
|
||||||
|
expect(res.statusCode).toBe(418)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('not found', () => {
|
||||||
|
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
|
||||||
|
'%s → 404 JSON',
|
||||||
|
async (url) => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const res = await app.inject({ method: 'GET', url })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||||
|
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||||
|
app = await buildTestApp({ STATIC_DIR: dir })
|
||||||
|
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||||
|
expect(spa.statusCode).toBe(200)
|
||||||
|
expect(spa.body).toContain('spa')
|
||||||
|
for (const url of ['/oauth/nope', '/.well-known/nope']) {
|
||||||
|
const res = await app.inject({ method: 'GET', url })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createDb, closeDb, migrateSchema } from '@authportal/db'
|
||||||
|
import { registerShutdown } from '../src/lib/shutdown.js'
|
||||||
|
import { buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
function fakeApp(close: () => Promise<void>) {
|
||||||
|
return {
|
||||||
|
close,
|
||||||
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
} as unknown as Parameters<typeof registerShutdown>[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerShutdown', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('закрывает приложение один раз и выходит с 0', async () => {
|
||||||
|
const close = vi.fn(async () => {})
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(fakeApp(close), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit,
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
|
||||||
|
expect(close).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
|
||||||
|
let release!: () => void
|
||||||
|
const close = vi.fn(
|
||||||
|
() => new Promise<void>((r) => (release = r)),
|
||||||
|
)
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(fakeApp(close), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit,
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
proc.emit('SIGINT')
|
||||||
|
expect(exit).toHaveBeenCalledWith(1)
|
||||||
|
expect(close).toHaveBeenCalledTimes(1)
|
||||||
|
release()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('по таймауту принудительно выходит с кодом 1', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(
|
||||||
|
fakeApp(() => new Promise<void>(() => {})),
|
||||||
|
{ timeoutMs: 500, exit, proc: proc as never },
|
||||||
|
)
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
await vi.advanceTimersByTimeAsync(501)
|
||||||
|
expect(exit).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ошибка close → выход с кодом 1', async () => {
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(
|
||||||
|
fakeApp(async () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}),
|
||||||
|
{ timeoutMs: 1000, exit, proc: proc as never },
|
||||||
|
)
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unhandledRejection запускает shutdown с кодом 1', async () => {
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(fakeApp(async () => {}), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit,
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
proc.emit('unhandledRejection', new Error('x'))
|
||||||
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispose снимает обработчики', () => {
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
const h = registerShutdown(fakeApp(async () => {}), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit: vi.fn(),
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
h.dispose()
|
||||||
|
expect(proc.listenerCount('SIGTERM')).toBe(0)
|
||||||
|
expect(proc.listenerCount('unhandledRejection')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('closeDb', () => {
|
||||||
|
it('идемпотентно закрывает БД', () => {
|
||||||
|
const { sqlite } = createDb('sqlite::memory:')
|
||||||
|
migrateSchema(sqlite)
|
||||||
|
closeDb(sqlite)
|
||||||
|
expect(sqlite.open).toBe(false)
|
||||||
|
expect(() => closeDb(sqlite)).not.toThrow()
|
||||||
|
expect(() => sqlite.prepare('SELECT 1')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('app.close() закрывает БД приложения', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
await app.ready()
|
||||||
|
expect(app.sqlite.open).toBe(true)
|
||||||
|
await app.close()
|
||||||
|
expect(app.sqlite.open).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -54,6 +54,8 @@ services:
|
|||||||
pull_policy: always
|
pull_policy: always
|
||||||
container_name: auth-portal
|
container_name: auth-portal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
stop_grace_period: 15s
|
||||||
depends_on:
|
depends_on:
|
||||||
- traefik
|
- traefik
|
||||||
# No host ports — only Traefik publishes 80/443.
|
# No host ports — only Traefik publishes 80/443.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=build --chown=node:node /out ./
|
COPY --from=build --chown=node:node /out ./
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
stop_grace_period: 15s
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -215,6 +215,17 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Идемпотентное закрытие: checkpoint WAL, обновление статистики планировщика, close. */
|
||||||
|
export function closeDb(sqlite: Sqlite): void {
|
||||||
|
if (!sqlite.open) return
|
||||||
|
try {
|
||||||
|
if (!sqlite.memory) sqlite.pragma('wal_checkpoint(TRUNCATE)')
|
||||||
|
sqlite.pragma('optimize')
|
||||||
|
} finally {
|
||||||
|
sqlite.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function healthCheck(sqlite: Sqlite): void {
|
export function healthCheck(sqlite: Sqlite): void {
|
||||||
sqlite.prepare('SELECT 1').get()
|
sqlite.prepare('SELECT 1').get()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user