// SPDX-License-Identifier: AGPL-3.0-or-later import {logBatch, logQuery} from '@app/api/database/CassandraDevLogger'; import {getIsDev} from '@app/api/database/CassandraMetaRegistry'; import type {CassandraParams, KvQueryMeta, PreparedQuery, QueryTemplate} from '@app/api/database/CassandraTypes'; import { assertNoUndefinedParams, getStatementMeta, isConditionalQuery, normalizeExecuteArgs, normalizeInParams, } from '@app/api/database/CassandraTypes'; import {Logger} from '@app/api/Logger'; import {chunkArray} from '@app/api/utils/ArrayUtils'; import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; import {getClient} from '@pkgs/cassandra/src/Client'; import cassandra from 'cassandra-driver'; const DEFAULT_MAX_PARTITION_KEYS_PER_QUERY = 100; function isDriverOverloadError(err: unknown): boolean { if (err instanceof cassandra.errors.BusyConnectionError) { return true; } if (err instanceof cassandra.errors.NoHostAvailableError && err.innerErrors) { return Object.values(err.innerErrors as Record).some( (innerError) => innerError instanceof cassandra.errors.BusyConnectionError, ); } return false; } export function mapCassandraDriverError(err: unknown): unknown { return isDriverOverloadError(err) ? new ServiceUnavailableError() : err; } export interface CassandraQueryExecutorForTesting { executeQuery, P extends CassandraParams = CassandraParams>( query: PreparedQuery

, ): Promise>; executePagedQuery?, P extends CassandraParams = CassandraParams>( query: PreparedQuery

, options: { pageSize: number; pageState?: string | null; }, ): Promise>; executeBatch(queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, atomic?: boolean): Promise; reset?(): void; shutdown?(): Promise; } let injectedExecutorForTesting: CassandraQueryExecutorForTesting | null = null; let configuredExecutor: CassandraQueryExecutorForTesting | null = null; function activeExecutor(): CassandraQueryExecutorForTesting | null { return injectedExecutorForTesting ?? configuredExecutor; } export function setDatabaseQueryExecutor(executor: CassandraQueryExecutorForTesting | null): void { configuredExecutor = executor; } export function hasDatabaseQueryExecutor(): boolean { return activeExecutor() !== null; } export function setCassandraQueryExecutorForTesting(executor: CassandraQueryExecutorForTesting | null): void { injectedExecutorForTesting = executor; } export function resetCassandraQueryExecutorForTesting(): void { injectedExecutorForTesting?.reset?.(); } export async function shutdownCassandraQueryExecutorForTesting(): Promise { await injectedExecutorForTesting?.shutdown?.(); injectedExecutorForTesting = null; } export interface PagedQueryResult { rows: Array; pageState: string | null; } interface CassandraReadOptions { consistency: 'all' | 'serial'; } async function collectSelectRows(queryType: string, result: cassandra.types.ResultSet): Promise> { if (queryType !== 'SELECT' || !result.pageState) { return (result.rows ?? []) as Array; } const rows: Array = []; for await (const row of result) { rows.push(row as T); } return rows; } export async function executeQuery, P extends CassandraParams = CassandraParams>( queryOrPrepared: string | PreparedQuery

, params?: P, readOptions?: CassandraReadOptions, ): Promise> { const {cql, params: boundRaw} = normalizeExecuteArgs(queryOrPrepared, params); const meta = getStatementMeta(cql); const bound = normalizeInParams(meta, boundRaw); if (meta.unsafe) { throw new Error('Cannot prepare a statement that looks like `SELECT *`'); } if (readOptions && meta.type !== 'SELECT') { throw new Error('Read consistency options require a SELECT query'); } assertNoUndefinedParams(bound as Record); const executor = activeExecutor(); if (executor) { return executor.executeQuery({ cql, params: bound as P, kvMeta: typeof queryOrPrepared === 'string' ? undefined : queryOrPrepared.kvMeta, }); } const isDev = getIsDev(); const startTime = isDev ? performance.now() : 0; try { const result = await getClient().execute(cql, bound, { prepare: true, ...(readOptions ? {consistency: cassandra.types.consistencies[readOptions.consistency]} : {}), }); const rows = await collectSelectRows(meta.type, result); if (isDev) { const durationMs = performance.now() - startTime; logQuery(meta.type, cql, bound as Record, durationMs, rows.length); } return rows; } catch (err: unknown) { const paramSummary: Record = {}; for (const [k, v] of Object.entries(bound as Record)) { if (typeof v === 'string') paramSummary[k] = {type: 'string', len: v.length}; else if (typeof v === 'bigint') paramSummary[k] = {type: 'bigint'}; else if (typeof v === 'number') paramSummary[k] = {type: 'number'}; else if (typeof v === 'boolean') paramSummary[k] = {type: 'boolean'}; else if (v instanceof Buffer) paramSummary[k] = {type: 'buffer', len: v.length}; else if (v instanceof Set) paramSummary[k] = {type: 'set', size: (v as Set).size}; else if (v instanceof Map) paramSummary[k] = {type: 'map', size: (v as Map).size}; else if (v instanceof Date) paramSummary[k] = {type: 'date'}; else if (Array.isArray(v)) paramSummary[k] = {type: 'array', len: v.length}; else if (v === null) paramSummary[k] = {type: 'null'}; else paramSummary[k] = {type: typeof v}; } const errorMessage = err instanceof Error ? err.message : String(err); Logger.warn({error: errorMessage, query: cql, params: paramSummary}, 'Cassandra query failed'); throw mapCassandraDriverError(err); } } export async function executeConditional(query: PreparedQuery): Promise { if (!isConditionalQuery(query)) { throw new Error('executeConditional requires a conditional query'); } const rows = await executeQuery(query); const applied = rows[0]?.['[applied]']; const isBatch = query.kvMeta?.action === 'batch'; if ( typeof applied !== 'boolean' || (!isBatch && rows.length !== 1) || rows.some((row) => row['[applied]'] !== applied) ) { throw new Error('Conditional write returned an invalid database result'); } return applied; } export async function fetchOne, P extends CassandraParams = CassandraParams>( queryOrPrepared: PreparedQuery

| string, params?: P, ): Promise { const [row] = await executeQuery(queryOrPrepared, params); return row ?? null; } export async function fetchMany, P extends CassandraParams = CassandraParams>( queryOrPrepared: PreparedQuery

| string, params?: P, readOptions?: CassandraReadOptions, ): Promise> { return executeQuery(queryOrPrepared, params, readOptions); } export async function fetchPage, P extends CassandraParams = CassandraParams>( queryOrPrepared: PreparedQuery

| string, params: P | undefined, options: { pageSize: number; pageState?: string | null; readTimeout?: number | undefined; }, ): Promise> { const {cql, params: boundRaw} = normalizeExecuteArgs(queryOrPrepared, params); const meta = getStatementMeta(cql); const bound = normalizeInParams(meta, boundRaw); if (meta.unsafe) { throw new Error('Cannot prepare a statement that looks like `SELECT *`'); } assertNoUndefinedParams(bound as Record); const executor = activeExecutor(); if (executor) { const preparedQuery = { cql, params: bound as P, kvMeta: typeof queryOrPrepared === 'string' ? undefined : queryOrPrepared.kvMeta, }; if (executor.executePagedQuery) { return executor.executePagedQuery(preparedQuery, options); } const rows = await executor.executeQuery(preparedQuery); return { rows: rows.slice(0, options.pageSize), pageState: null, }; } const result = await getClient().execute(cql, bound, { prepare: true, fetchSize: options.pageSize, pageState: options.pageState ?? undefined, readTimeout: options.readTimeout, }); return { rows: (result.rows as Array) ?? [], pageState: result.pageState ?? null, }; } export async function fetchManyInChunks< T = Record, V = unknown, P extends CassandraParams = CassandraParams, >( query: QueryTemplate

| PreparedQuery

| string, values: Array, paramsFactory: (chunk: Array) => P, chunkSize = DEFAULT_MAX_PARTITION_KEYS_PER_QUERY, ): Promise> { if (values.length === 0) return []; const chunks = chunkArray(values, chunkSize); const results = await Promise.all( chunks.map(async (chunk) => { const params = paramsFactory(chunk); if (typeof query === 'string') { return executeQuery(query, params); } if ((query as PreparedQuery

).params !== undefined) { return executeQuery(query as PreparedQuery

); } return executeQuery((query as QueryTemplate

).bind(params)); }), ); return results.flat(); } export async function upsertOne

( queryOrPrepared: PreparedQuery

| string, params?: P, ): Promise { assertUnconditional(normalizeExecuteArgs(queryOrPrepared, params)); await executeQuery(queryOrPrepared, params); } export async function deleteOneOrMany

( queryOrPrepared: PreparedQuery

| string, params?: P, ): Promise { assertUnconditional(normalizeExecuteArgs(queryOrPrepared, params)); await executeQuery(queryOrPrepared, params); } function assertUnconditional(query: PreparedQuery): void { if (isConditionalQuery(query)) { throw new Error('Conditional writes must use executeConditional to preserve their result'); } } interface BatchQuery { query: string; params: object; meta?: KvQueryMeta; } async function executeBatch(queries: Array, atomic = true): Promise { if (queries.length === 0) return; for (const {query, params, meta} of queries) { assertUnconditional({cql: query, params: params as CassandraParams, kvMeta: meta}); if (getStatementMeta(query).unsafe) { throw new Error('Cannot prepare a statement that looks like `SELECT *`'); } } for (const {params} of queries) { assertNoUndefinedParams(params as Record); } const executor = activeExecutor(); if (executor) { await executor.executeBatch(queries, atomic); return; } const options = { prepare: true, logged: atomic, counter: false, }; const isDev = getIsDev(); const startTime = isDev ? performance.now() : 0; try { await getClient().batch( queries.map(({query, params}) => ({ query, params: normalizeInParams(getStatementMeta(query), params as CassandraParams), })), options, ); } catch (err: unknown) { throw mapCassandraDriverError(err); } if (isDev) { const durationMs = performance.now() - startTime; logBatch(queries, durationMs); } } export class BatchBuilder { private queries: Array = []; add(query: string, params: object, meta?: KvQueryMeta): this { this.queries.push({query, params, meta}); return this; } addPrepared(q: PreparedQuery): this { this.queries.push({query: q.cql, params: q.params, meta: q.kvMeta}); return this; } addIf(condition: boolean, query: string, params: object, meta?: KvQueryMeta): this { if (condition) this.queries.push({query, params, meta}); return this; } addPreparedIf(condition: boolean, q: PreparedQuery): this { if (condition) this.queries.push({query: q.cql, params: q.params, meta: q.kvMeta}); return this; } async execute(atomic = true): Promise { if (this.queries.length === 0) return; await executeBatch(this.queries, atomic); } async executeChunked(chunkSize: number, atomic = false): Promise { if (this.queries.length === 0) return; for (let i = 0; i < this.queries.length; i += chunkSize) { await executeBatch(this.queries.slice(i, i + chunkSize), atomic); } } getQueries(): Array { return this.queries; } } const MAX_BATCH_STATEMENTS = 60; export async function executeGroupedBatches( groups: ReadonlyArray>, maxStatements = MAX_BATCH_STATEMENTS, ): Promise { let batch = new BatchBuilder(); let size = 0; for (const group of groups) { if (size > 0 && size + group.length > maxStatements) { await batch.execute(); batch = new BatchBuilder(); size = 0; } for (const query of group) batch.addPrepared(query); size += group.length; } await batch.execute(); }