mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
322 lines
9.6 KiB
TypeScript
322 lines
9.6 KiB
TypeScript
// PostgreSQL distributed quota authority for authenticated management operations.
|
|
import type { PostgresPool } from '@qinglong/runtime-core';
|
|
import {
|
|
PLUGIN_PACKAGE_MANAGEMENT_QUOTA_OPERATIONS,
|
|
PluginPackageManagementQuotaExceededError,
|
|
PluginPackageManagementUnavailableError,
|
|
type ConsumePluginPackageManagementQuotaCommand,
|
|
type PluginPackageManagementQuotaOperation,
|
|
type PluginPackageManagementQuotaPort,
|
|
type PluginPackageManagementQuotaResult,
|
|
} from '@qinglong/runtime-core/plugin-package-management';
|
|
|
|
import {
|
|
postgresRequiredBoolean,
|
|
postgresRequiredInteger,
|
|
} from '../repository/definitionRepositorySupport';
|
|
|
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
const DEFAULT_LIMITS = Object.freeze({
|
|
'plugin-package.propose': 30,
|
|
'plugin-package.decide': 60,
|
|
'plugin-package.inspect': 600,
|
|
});
|
|
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
export interface PostgresPluginPackageManagementQuotaOptions {
|
|
readonly windowMs?: number;
|
|
readonly limits?: Partial<
|
|
Readonly<Record<PluginPackageManagementQuotaOperation, number>>
|
|
>;
|
|
}
|
|
|
|
function unavailable(cause?: unknown): PluginPackageManagementUnavailableError {
|
|
return new PluginPackageManagementUnavailableError({
|
|
cause: cause instanceof Error ? cause : undefined,
|
|
});
|
|
}
|
|
|
|
function integer(
|
|
value: number | undefined,
|
|
fallback: number,
|
|
minimum: number,
|
|
maximum: number,
|
|
label: string,
|
|
): number {
|
|
const candidate = value ?? fallback;
|
|
if (
|
|
!Number.isSafeInteger(candidate) ||
|
|
candidate < minimum ||
|
|
candidate > maximum
|
|
) {
|
|
throw new TypeError(`PostgreSQL Package management ${label} is invalid`);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function reviewedOptions(
|
|
value: PostgresPluginPackageManagementQuotaOptions | undefined,
|
|
): Readonly<{
|
|
windowMs: number;
|
|
limits: Readonly<Record<PluginPackageManagementQuotaOperation, number>>;
|
|
}> {
|
|
if (
|
|
value !== undefined &&
|
|
(!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
Object.keys(value).some((key) => key !== 'windowMs' && key !== 'limits'))
|
|
) {
|
|
throw new TypeError(
|
|
'PostgreSQL Package management quota options are invalid',
|
|
);
|
|
}
|
|
const limits = value?.limits;
|
|
if (
|
|
limits !== undefined &&
|
|
(!limits ||
|
|
typeof limits !== 'object' ||
|
|
Array.isArray(limits) ||
|
|
Object.keys(limits).some(
|
|
(key) =>
|
|
!PLUGIN_PACKAGE_MANAGEMENT_QUOTA_OPERATIONS.includes(
|
|
key as PluginPackageManagementQuotaOperation,
|
|
),
|
|
))
|
|
) {
|
|
throw new TypeError(
|
|
'PostgreSQL Package management quota limits are invalid',
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
windowMs: integer(
|
|
value?.windowMs,
|
|
DEFAULT_WINDOW_MS,
|
|
1_000,
|
|
5 * 60_000,
|
|
'quota window',
|
|
),
|
|
limits: Object.freeze({
|
|
'plugin-package.propose': integer(
|
|
limits?.['plugin-package.propose'],
|
|
DEFAULT_LIMITS['plugin-package.propose'],
|
|
1,
|
|
1_000,
|
|
'proposal quota',
|
|
),
|
|
'plugin-package.decide': integer(
|
|
limits?.['plugin-package.decide'],
|
|
DEFAULT_LIMITS['plugin-package.decide'],
|
|
1,
|
|
1_000,
|
|
'decision quota',
|
|
),
|
|
'plugin-package.inspect': integer(
|
|
limits?.['plugin-package.inspect'],
|
|
DEFAULT_LIMITS['plugin-package.inspect'],
|
|
1,
|
|
1_000,
|
|
'inspection quota',
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
|
|
function validateCommand(
|
|
command: ConsumePluginPackageManagementQuotaCommand,
|
|
): Readonly<ConsumePluginPackageManagementQuotaCommand> {
|
|
if (
|
|
!command ||
|
|
typeof command !== 'object' ||
|
|
Array.isArray(command) ||
|
|
Object.keys(command).some(
|
|
(key) =>
|
|
!['projectId', 'subject', 'operation', 'idempotencyKey'].includes(key),
|
|
) ||
|
|
Object.keys(command).length !== 4 ||
|
|
typeof command.projectId !== 'string' ||
|
|
!PROJECT_PATTERN.test(command.projectId) ||
|
|
!command.subject ||
|
|
typeof command.subject !== 'object' ||
|
|
Array.isArray(command.subject) ||
|
|
Object.keys(command.subject).length !== 2 ||
|
|
command.subject.type !== 'user' ||
|
|
typeof command.subject.id !== 'string' ||
|
|
command.subject.id.length < 1 ||
|
|
command.subject.id.length > 255 ||
|
|
/[\u0000-\u001f\u007f]/.test(command.subject.id) ||
|
|
!PLUGIN_PACKAGE_MANAGEMENT_QUOTA_OPERATIONS.includes(command.operation) ||
|
|
typeof command.idempotencyKey !== 'string' ||
|
|
!IDENTIFIER_PATTERN.test(command.idempotencyKey)
|
|
) {
|
|
throw new TypeError(
|
|
'PostgreSQL Package management quota command is invalid',
|
|
);
|
|
}
|
|
return command;
|
|
}
|
|
|
|
export class PostgresPluginPackageManagementQuotaRepository
|
|
implements PluginPackageManagementQuotaPort
|
|
{
|
|
readonly #windowMs: number;
|
|
readonly #limits: Readonly<
|
|
Record<PluginPackageManagementQuotaOperation, number>
|
|
>;
|
|
|
|
constructor(
|
|
private readonly pool: PostgresPool,
|
|
options?: PostgresPluginPackageManagementQuotaOptions,
|
|
) {
|
|
if (!pool || typeof pool.query !== 'function') {
|
|
throw new TypeError(
|
|
'PostgreSQL Package management quota pool is invalid',
|
|
);
|
|
}
|
|
const reviewed = reviewedOptions(options);
|
|
this.#windowMs = reviewed.windowMs;
|
|
this.#limits = reviewed.limits;
|
|
}
|
|
|
|
async consume(
|
|
commandValue: ConsumePluginPackageManagementQuotaCommand,
|
|
): Promise<Readonly<PluginPackageManagementQuotaResult>> {
|
|
const command = validateCommand(commandValue);
|
|
const limit = this.#limits[command.operation];
|
|
let result;
|
|
try {
|
|
result = await this.pool.query<Row>(
|
|
`
|
|
WITH database_clock AS (
|
|
SELECT floor(
|
|
extract(epoch FROM clock_timestamp()) * 1000
|
|
)::bigint AS now_ms
|
|
)
|
|
INSERT INTO "ql3"."plugin_package_management_quota_buckets" (
|
|
project_id, subject_type, subject_id, operation,
|
|
window_started_at_ms, consumed_count, receipt_ids, updated_at_ms
|
|
)
|
|
SELECT
|
|
$1, $2, $3, $4,
|
|
(now_ms / $6::bigint) * $6::bigint,
|
|
1,
|
|
jsonb_build_array($5::text),
|
|
now_ms
|
|
FROM database_clock
|
|
ON CONFLICT (project_id, subject_type, subject_id, operation)
|
|
DO UPDATE SET
|
|
window_started_at_ms = CASE
|
|
WHEN "plugin_package_management_quota_buckets".window_started_at_ms
|
|
+ $6::bigint <= EXCLUDED.updated_at_ms
|
|
THEN (EXCLUDED.updated_at_ms / $6::bigint) * $6::bigint
|
|
ELSE "plugin_package_management_quota_buckets".window_started_at_ms
|
|
END,
|
|
consumed_count = CASE
|
|
WHEN "plugin_package_management_quota_buckets".window_started_at_ms
|
|
+ $6::bigint <= EXCLUDED.updated_at_ms
|
|
THEN 1
|
|
WHEN "plugin_package_management_quota_buckets".receipt_ids ? $5::text
|
|
THEN "plugin_package_management_quota_buckets".consumed_count
|
|
ELSE "plugin_package_management_quota_buckets".consumed_count + 1
|
|
END,
|
|
receipt_ids = CASE
|
|
WHEN "plugin_package_management_quota_buckets".window_started_at_ms
|
|
+ $6::bigint <= EXCLUDED.updated_at_ms
|
|
THEN jsonb_build_array($5::text)
|
|
WHEN "plugin_package_management_quota_buckets".receipt_ids ? $5::text
|
|
THEN "plugin_package_management_quota_buckets".receipt_ids
|
|
ELSE "plugin_package_management_quota_buckets".receipt_ids
|
|
|| jsonb_build_array($5::text)
|
|
END,
|
|
updated_at_ms = EXCLUDED.updated_at_ms
|
|
WHERE
|
|
"plugin_package_management_quota_buckets".window_started_at_ms
|
|
+ $6::bigint <= EXCLUDED.updated_at_ms
|
|
OR "plugin_package_management_quota_buckets".receipt_ids ? $5::text
|
|
OR "plugin_package_management_quota_buckets".consumed_count < $7::integer
|
|
RETURNING
|
|
true AS admitted,
|
|
consumed_count AS "consumedCount",
|
|
window_started_at_ms + $6::bigint AS "resetAtMs",
|
|
updated_at_ms AS "observedAtMs"
|
|
`.trim(),
|
|
[
|
|
command.projectId,
|
|
command.subject.type,
|
|
command.subject.id,
|
|
command.operation,
|
|
command.idempotencyKey,
|
|
this.#windowMs,
|
|
limit,
|
|
],
|
|
);
|
|
} catch (error) {
|
|
throw unavailable(error);
|
|
}
|
|
if (result.rows.length === 0) {
|
|
try {
|
|
result = await this.pool.query<Row>(
|
|
`
|
|
WITH database_clock AS (
|
|
SELECT floor(
|
|
extract(epoch FROM clock_timestamp()) * 1000
|
|
)::bigint AS now_ms
|
|
)
|
|
SELECT
|
|
false AS admitted,
|
|
buckets.consumed_count AS "consumedCount",
|
|
buckets.window_started_at_ms + $5::bigint AS "resetAtMs",
|
|
database_clock.now_ms AS "observedAtMs"
|
|
FROM "ql3"."plugin_package_management_quota_buckets" AS buckets
|
|
CROSS JOIN database_clock
|
|
WHERE buckets.project_id = $1
|
|
AND buckets.subject_type = $2
|
|
AND buckets.subject_id = $3
|
|
AND buckets.operation = $4
|
|
LIMIT 2
|
|
`.trim(),
|
|
[
|
|
command.projectId,
|
|
command.subject.type,
|
|
command.subject.id,
|
|
command.operation,
|
|
this.#windowMs,
|
|
],
|
|
);
|
|
} catch (error) {
|
|
throw unavailable(error);
|
|
}
|
|
}
|
|
if (result.rows.length !== 1) throw unavailable();
|
|
const row = result.rows[0]!;
|
|
const admitted = postgresRequiredBoolean(row.admitted, unavailable);
|
|
const consumedCount = postgresRequiredInteger(
|
|
row.consumedCount,
|
|
unavailable,
|
|
);
|
|
const resetAtMs = postgresRequiredInteger(row.resetAtMs, unavailable);
|
|
const observedAtMs = postgresRequiredInteger(row.observedAtMs, unavailable);
|
|
if (
|
|
consumedCount < 1 ||
|
|
consumedCount > limit ||
|
|
resetAtMs <= observedAtMs ||
|
|
resetAtMs > observedAtMs + this.#windowMs
|
|
) {
|
|
throw unavailable();
|
|
}
|
|
if (!admitted) {
|
|
throw new PluginPackageManagementQuotaExceededError(
|
|
Math.max(1, resetAtMs - observedAtMs),
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
remaining: limit - consumedCount,
|
|
resetAtMs,
|
|
observedAtMs,
|
|
});
|
|
}
|
|
}
|