mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {spawnSync} from 'node:child_process';
|
|
import {createServer} from 'node:net';
|
|
import type {CassandraParams, KvQueryMeta, KvTableSpec, WhereExpr} from '@app/api/database/CassandraTypes';
|
|
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
|
|
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
|
import {
|
|
getDefaultPostgresClient,
|
|
type IPostgresClient,
|
|
initPostgres,
|
|
shutdownPostgres,
|
|
} from '@pkgs/postgres/src/Client';
|
|
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
interface Statement {
|
|
text: string;
|
|
name: string | undefined;
|
|
}
|
|
|
|
const KV_TABLE = 'kv_stmt_names';
|
|
const KV_TABLE_POOLED = 'kv_stmt_names_pooled';
|
|
const CONTAINER = `fluxer-kvstmt-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
|
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
|
|
|
async function sleep(ms: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function freePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.on('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address();
|
|
if (typeof address === 'string' || address === null) {
|
|
reject(new Error('no port'));
|
|
return;
|
|
}
|
|
const port = address.port;
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
const Composite: KvTableSpec<Row> = {
|
|
name: 'stmt_composite',
|
|
columns: ['owner_id', 'item_id', 'payload'],
|
|
primaryKey: ['owner_id', 'item_id'],
|
|
partitionKey: ['owner_id'],
|
|
};
|
|
|
|
const Expiring: KvTableSpec<Row> = {...Composite, name: 'stmt_expiring', defaultTtlSeconds: 600};
|
|
|
|
const Bucketed: KvTableSpec<Row> = {
|
|
name: 'stmt_bucketed',
|
|
columns: ['bucket', 'item_id', 'payload'],
|
|
primaryKey: ['item_id'],
|
|
partitionKey: ['bucket'],
|
|
};
|
|
|
|
function recordingClient(statements: Array<Statement>): IPostgresClient {
|
|
const client = {
|
|
async query(text: string, _values?: Array<unknown>, name?: string) {
|
|
statements.push({text, name});
|
|
const rows = text.startsWith('SELECT kv.row_key')
|
|
? [{row_key: 'stmt_row', row_data: {owner_id: 'o1', item_id: 'i1', payload: 'p1'}}]
|
|
: [];
|
|
return {rows, rowCount: rows.length};
|
|
},
|
|
async connect() {},
|
|
async shutdown() {},
|
|
isConnected() {
|
|
return true;
|
|
},
|
|
async transaction(fn: (db: unknown) => Promise<unknown>) {
|
|
return fn(client);
|
|
},
|
|
kvTable() {
|
|
return KV_TABLE;
|
|
},
|
|
} as never;
|
|
return client;
|
|
}
|
|
|
|
function meta(spec: KvTableSpec<Row>, action: string, where: Array<WhereExpr<Row>>, extra: Row = {}): KvQueryMeta<Row> {
|
|
return {action, table: spec, where, columns: spec.columns, ...extra} as unknown as KvQueryMeta<Row>;
|
|
}
|
|
|
|
const eq = (col: string): WhereExpr<Row> => ({kind: 'eq', col, param: col}) as WhereExpr<Row>;
|
|
const isIn = (col: string): WhereExpr<Row> => ({kind: 'in', col, param: col}) as WhereExpr<Row>;
|
|
|
|
const OWNER_ITEM = {owner_id: 'o1', item_id: 'i1', payload: 'p1'} as CassandraParams;
|
|
const BUCKET = {bucket: 'b1', item_id: 'i1', payload: 'p1'} as CassandraParams;
|
|
|
|
async function runShapes(): Promise<Array<Statement>> {
|
|
const statements: Array<Statement> = [];
|
|
const executor = new PostgresKvQueryExecutor(recordingClient(statements));
|
|
const cases: Array<[KvQueryMeta<Row>, CassandraParams]> = [
|
|
[meta(Composite, 'select', [eq('owner_id'), eq('item_id')]), OWNER_ITEM],
|
|
[meta(Composite, 'select', [eq('owner_id')]), OWNER_ITEM],
|
|
[meta(Composite, 'select', [isIn('owner_id')]), {owner_id: ['o1', 'o2']} as CassandraParams],
|
|
[meta(Composite, 'select', []), {} as CassandraParams],
|
|
[meta(Bucketed, 'select', [eq('bucket')]), BUCKET],
|
|
[meta(Bucketed, 'select', [isIn('bucket')]), {bucket: ['b1', 'b2']} as CassandraParams],
|
|
[meta(Composite, 'count', [eq('owner_id'), eq('item_id')]), OWNER_ITEM],
|
|
[meta(Composite, 'count', [eq('owner_id')]), OWNER_ITEM],
|
|
[meta(Composite, 'count', [isIn('owner_id')]), {owner_id: ['o1', 'o2']} as CassandraParams],
|
|
[meta(Composite, 'count', []), {} as CassandraParams],
|
|
[meta(Bucketed, 'count', [eq('bucket')]), BUCKET],
|
|
[meta(Bucketed, 'count', [isIn('bucket')]), {bucket: ['b1', 'b2']} as CassandraParams],
|
|
[meta(Composite, 'delete', [eq('owner_id'), eq('item_id')]), OWNER_ITEM],
|
|
[meta(Composite, 'delete', [eq('owner_id'), eq('payload')]), OWNER_ITEM],
|
|
[meta(Composite, 'upsert', []), OWNER_ITEM],
|
|
[meta(Composite, 'upsert', [], {ifNotExists: true}), OWNER_ITEM],
|
|
[meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}), OWNER_ITEM],
|
|
[
|
|
meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload'], ttlParamName: 'ttl_'}),
|
|
{...OWNER_ITEM, ttl_: 600} as CassandraParams,
|
|
],
|
|
[meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}), OWNER_ITEM],
|
|
];
|
|
for (const [kvMeta, params] of cases) {
|
|
await executor.executeQuery({cql: `__stmt_${kvMeta.action}`, params, kvMeta: kvMeta as KvQueryMeta});
|
|
}
|
|
return statements;
|
|
}
|
|
|
|
describe('PostgresKvQueryExecutor statement names', () => {
|
|
it('names every key-pinned statement shape exactly once', async () => {
|
|
const statements = await runShapes();
|
|
const named = new Map<string, string>();
|
|
for (const statement of statements) {
|
|
if (statement.name === undefined) continue;
|
|
const seen = named.get(statement.name);
|
|
expect(seen ?? statement.text).toBe(statement.text);
|
|
named.set(statement.name, statement.text);
|
|
}
|
|
expect([...named.keys()].sort()).toEqual([
|
|
'kv_count_range',
|
|
'kv_count_rowkeys',
|
|
'kv_del_expired',
|
|
'kv_del_keys',
|
|
'kv_del_rowkeys',
|
|
'kv_get_row',
|
|
'kv_patch_default_ttl',
|
|
'kv_patch_keep_ttl',
|
|
'kv_patch_set_ttl',
|
|
'kv_sel_range',
|
|
'kv_sel_rowkeys',
|
|
'kv_upsert',
|
|
]);
|
|
});
|
|
|
|
it('leaves the OR-of-ranges, partition and scan shapes unnamed', async () => {
|
|
const statements = await runShapes();
|
|
const shapes = [' OR (', 'kv.partition_key = $2', 'kv.partition_key = ANY($2::text[])', '$1 AND (kv.expires_at'];
|
|
for (const shape of shapes) {
|
|
const matching = statements.filter((statement) => statement.text.includes(shape));
|
|
expect(matching.length).toBeGreaterThan(0);
|
|
expect(matching.filter((statement) => statement.name !== undefined)).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('never reuses one statement text under two names', async () => {
|
|
const namesByText = new Map<string, Set<string>>();
|
|
for (const statement of await runShapes()) {
|
|
if (statement.name === undefined) continue;
|
|
const names = namesByText.get(statement.text) ?? new Set<string>();
|
|
names.add(statement.name);
|
|
namesByText.set(statement.text, names);
|
|
}
|
|
expect([...namesByText.values()].filter((names) => names.size > 1)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
async function exerciseKvShapes(executor: PostgresKvQueryExecutor): Promise<void> {
|
|
for (let index = 0; index < 4; index += 1) {
|
|
await executor.executeQuery({
|
|
cql: '__stmt_seed',
|
|
params: {owner_id: `o${index % 2}`, item_id: `i${index}`, payload: `p${index}`} as CassandraParams,
|
|
kvMeta: meta(Composite, 'upsert', []) as KvQueryMeta,
|
|
});
|
|
}
|
|
for (let index = 0; index < 12; index += 1) {
|
|
const point = await executor.executeQuery<Row>({
|
|
cql: '__stmt_point',
|
|
params: {owner_id: 'o0', item_id: 'i0'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
|
});
|
|
expect(point.map((row) => row.payload)).toEqual(['p0']);
|
|
const range = await executor.executeQuery<Row>({
|
|
cql: '__stmt_range',
|
|
params: {owner_id: 'o0'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'select', [eq('owner_id')]) as KvQueryMeta,
|
|
});
|
|
expect(range).toHaveLength(2);
|
|
}
|
|
await executor.executeQuery({
|
|
cql: '__stmt_patch',
|
|
params: {owner_id: 'o0', item_id: 'i0', payload: 'patched'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}) as KvQueryMeta,
|
|
});
|
|
const reapplied = await executor.executeQuery<Row>({
|
|
cql: '__stmt_lwt',
|
|
params: {owner_id: 'o0', item_id: 'i0', payload: 'ignored'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'upsert', [], {ifNotExists: true}) as KvQueryMeta,
|
|
});
|
|
expect(reapplied).toEqual([{'[applied]': false}]);
|
|
const claimed = await executor.executeQuery<Row>({
|
|
cql: '__stmt_lwt',
|
|
params: {owner_id: 'o9', item_id: 'i9', payload: 'claimed'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'upsert', [], {ifNotExists: true}) as KvQueryMeta,
|
|
});
|
|
expect(claimed).toEqual([{'[applied]': true}]);
|
|
await executor.executeQuery({
|
|
cql: '__stmt_patch_ttl',
|
|
params: {owner_id: 'o9', item_id: 'i9', payload: 'expiring', ttl_: 600} as CassandraParams,
|
|
kvMeta: meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {
|
|
patchKeys: ['payload'],
|
|
ttlParamName: 'ttl_',
|
|
}) as KvQueryMeta,
|
|
});
|
|
const expiring = await executor.executeQuery<Row>({
|
|
cql: '__stmt_point',
|
|
params: {owner_id: 'o9', item_id: 'i9'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
|
});
|
|
expect(expiring.map((row) => row.payload)).toEqual(['expiring']);
|
|
const patched = await executor.executeQuery<Row>({
|
|
cql: '__stmt_point',
|
|
params: {owner_id: 'o0', item_id: 'i0'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
|
});
|
|
expect(patched.map((row) => row.payload)).toEqual(['patched']);
|
|
await executor.executeQuery({
|
|
cql: '__stmt_patch_default_ttl',
|
|
params: {owner_id: 'o5', item_id: 'i5', payload: 'defaulted'} as CassandraParams,
|
|
kvMeta: meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}) as KvQueryMeta,
|
|
});
|
|
const defaulted = await executor.executeQuery<Row>({
|
|
cql: '__stmt_point',
|
|
params: {owner_id: 'o5', item_id: 'i5'} as CassandraParams,
|
|
kvMeta: meta(Expiring, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
|
});
|
|
expect(defaulted.map((row) => row.payload)).toEqual(['defaulted']);
|
|
await executor.executeQuery({
|
|
cql: '__stmt_delete',
|
|
params: {owner_id: 'o0', item_id: 'i0'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'delete', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
|
});
|
|
const remaining = await executor.executeQuery<Row>({
|
|
cql: '__stmt_range',
|
|
params: {owner_id: 'o0'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'select', [eq('owner_id')]) as KvQueryMeta,
|
|
});
|
|
expect(remaining).toHaveLength(1);
|
|
await executor.executeBatch([
|
|
{
|
|
query: '__stmt_batch',
|
|
params: {owner_id: 'o7', item_id: 'i7', payload: 'batched'},
|
|
meta: meta(Composite, 'upsert', []) as KvQueryMeta,
|
|
},
|
|
{
|
|
query: '__stmt_batch',
|
|
params: {owner_id: 'o7', item_id: 'i8', payload: 'batched'},
|
|
meta: meta(Composite, 'upsert', []) as KvQueryMeta,
|
|
},
|
|
]);
|
|
const batched = await executor.executeQuery<Row>({
|
|
cql: '__stmt_range',
|
|
params: {owner_id: 'o7'} as CassandraParams,
|
|
kvMeta: meta(Composite, 'select', [eq('owner_id')]) as KvQueryMeta,
|
|
});
|
|
expect(batched.map((row) => row.payload)).toEqual(['batched', 'batched']);
|
|
}
|
|
|
|
describe.skipIf(!dockerAvailable)('PostgresKvQueryExecutor statement names against postgres', () => {
|
|
let raw: IPostgresClient;
|
|
let executor: PostgresKvQueryExecutor;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
port = await freePort();
|
|
startDockerContainer([
|
|
'run',
|
|
'-d',
|
|
'--name',
|
|
CONTAINER,
|
|
'-e',
|
|
'POSTGRES_USER=fluxer',
|
|
'-e',
|
|
'POSTGRES_PASSWORD=fluxer',
|
|
'-e',
|
|
'POSTGRES_DB=fluxer',
|
|
'-p',
|
|
`127.0.0.1:${port}:5432`,
|
|
'postgres:16-alpine',
|
|
'-c',
|
|
'fsync=off',
|
|
]);
|
|
let ready = false;
|
|
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
|
await sleep(500);
|
|
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
|
stdio: 'ignore',
|
|
});
|
|
if (probe.status !== 0) continue;
|
|
try {
|
|
await initPostgres({
|
|
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
|
|
maxConnections: 1,
|
|
kvTable: KV_TABLE,
|
|
});
|
|
await getDefaultPostgresClient().query('SELECT 1');
|
|
ready = true;
|
|
} catch {
|
|
await shutdownPostgres().catch(() => {});
|
|
}
|
|
}
|
|
if (!ready) throw new Error('postgres never came up');
|
|
raw = getDefaultPostgresClient();
|
|
await ensurePostgresKvSchema(raw);
|
|
executor = new PostgresKvQueryExecutor(raw);
|
|
}, 900_000);
|
|
|
|
afterAll(async () => {
|
|
await shutdownPostgres().catch(() => {});
|
|
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
|
});
|
|
|
|
it('prepares each named shape server side and keeps reading the right rows', async () => {
|
|
await exerciseKvShapes(executor);
|
|
const prepared = await raw.query<{name: string}>('SELECT name FROM pg_prepared_statements ORDER BY name');
|
|
expect(prepared.rows.map((row) => row.name)).toEqual([
|
|
'kv_del_expired',
|
|
'kv_del_rowkeys',
|
|
'kv_get_row',
|
|
'kv_patch_default_ttl',
|
|
'kv_patch_keep_ttl',
|
|
'kv_patch_set_ttl',
|
|
'kv_sel_range',
|
|
'kv_sel_rowkeys',
|
|
'kv_upsert',
|
|
]);
|
|
});
|
|
|
|
it('prepares nothing server side when prepared statements are disabled', async () => {
|
|
await initPostgres({
|
|
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
|
|
maxConnections: 1,
|
|
kvTable: KV_TABLE_POOLED,
|
|
preparedStatements: false,
|
|
});
|
|
const pooled = getDefaultPostgresClient();
|
|
await ensurePostgresKvSchema(pooled);
|
|
await exerciseKvShapes(new PostgresKvQueryExecutor(pooled));
|
|
const prepared = await pooled.query<{name: string}>('SELECT name FROM pg_prepared_statements ORDER BY name');
|
|
expect(prepared.rows).toEqual([]);
|
|
});
|
|
});
|