355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import {
|
|
and,
|
|
connectorLeaseAuditLog,
|
|
eq,
|
|
gt,
|
|
isNull,
|
|
logicalAgentConnectorLeases,
|
|
sql,
|
|
type Db,
|
|
} from '@mosaicstack/db';
|
|
import { ConnectorLeaseError } from '@mosaicstack/agent';
|
|
import type {
|
|
ConnectorLease,
|
|
ConnectorLeaseAcquireMutation,
|
|
ConnectorLeaseAuditEvent,
|
|
ConnectorLeaseHeartbeatMutation,
|
|
ConnectorLeaseRejectReason,
|
|
ConnectorLeaseReleaseMutation,
|
|
ConnectorLeaseStore,
|
|
ConnectorLeaseTakeoverMutation,
|
|
LogicalAgentBinding,
|
|
} from '@mosaicstack/types';
|
|
import { DB } from '../database/database.module.js';
|
|
|
|
interface SuccessfulMutation {
|
|
readonly ok: true;
|
|
readonly lease: ConnectorLease;
|
|
}
|
|
|
|
interface FailedMutation {
|
|
readonly ok: false;
|
|
readonly reason: ConnectorLeaseRejectReason;
|
|
}
|
|
|
|
type MutationResult = SuccessfulMutation | FailedMutation;
|
|
|
|
@Injectable()
|
|
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
|
|
constructor(@Inject(DB) private readonly db: Db) {}
|
|
|
|
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
|
|
const result: MutationResult = await this.db.transaction(
|
|
async (tx): Promise<MutationResult> => {
|
|
const inserted = await tx
|
|
.insert(logicalAgentConnectorLeases)
|
|
.values({
|
|
leaseId: input.leaseId,
|
|
tenantId: input.identity.tenantId,
|
|
logicalAgentId: input.identity.logicalAgentId,
|
|
bindingId: input.bindingId,
|
|
connectorId: input.connectorId,
|
|
scopes: [...input.scopes],
|
|
leaseEpoch: 1n,
|
|
acquiredAt: new Date(input.now),
|
|
heartbeatAt: new Date(input.now),
|
|
expiresAt: new Date(input.expiresAt),
|
|
updatedAt: new Date(input.now),
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
const row = inserted[0];
|
|
if (row) {
|
|
const lease = toLease(row);
|
|
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
|
|
return { ok: true, lease };
|
|
}
|
|
|
|
const current = await findRow(tx, input);
|
|
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
}
|
|
const reason: ConnectorLeaseRejectReason =
|
|
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
|
|
? 'takeover_required'
|
|
: 'lease_held';
|
|
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
|
|
return { ok: false, reason };
|
|
},
|
|
);
|
|
return unwrap(result);
|
|
}
|
|
|
|
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
|
|
const result: MutationResult = await this.db.transaction(
|
|
async (tx): Promise<MutationResult> => {
|
|
const current = await findRow(tx, input);
|
|
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
|
|
await insertAudit(
|
|
tx,
|
|
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
|
|
);
|
|
return { ok: false, reason: 'cas_mismatch' };
|
|
}
|
|
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
}
|
|
const updated = await tx
|
|
.update(logicalAgentConnectorLeases)
|
|
.set({
|
|
leaseId: input.leaseId,
|
|
connectorId: input.connectorId,
|
|
scopes: [...input.scopes],
|
|
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
|
|
acquiredAt: new Date(input.now),
|
|
heartbeatAt: new Date(input.now),
|
|
expiresAt: new Date(input.expiresAt),
|
|
releasedAt: null,
|
|
updatedAt: new Date(input.now),
|
|
})
|
|
.where(
|
|
and(
|
|
bindingPredicate(input),
|
|
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
|
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
|
|
),
|
|
)
|
|
.returning();
|
|
const row = updated[0];
|
|
if (!row) {
|
|
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
|
|
return { ok: false, reason: 'cas_mismatch' };
|
|
}
|
|
const lease = toLease(row);
|
|
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
|
|
return { ok: true, lease };
|
|
},
|
|
);
|
|
return unwrap(result);
|
|
}
|
|
|
|
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
|
|
const result: MutationResult = await this.db.transaction(
|
|
async (tx): Promise<MutationResult> => {
|
|
const updated = await tx
|
|
.update(logicalAgentConnectorLeases)
|
|
.set({
|
|
heartbeatAt: new Date(input.now),
|
|
expiresAt: new Date(input.expiresAt),
|
|
updatedAt: new Date(input.now),
|
|
})
|
|
.where(
|
|
and(
|
|
bindingPredicate(input.lease),
|
|
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
|
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
|
isNull(logicalAgentConnectorLeases.releasedAt),
|
|
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
|
),
|
|
)
|
|
.returning();
|
|
const row = updated[0];
|
|
if (row) {
|
|
const lease = toLease(row);
|
|
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
|
|
return { ok: true, lease };
|
|
}
|
|
const current = await findRow(tx, input.lease);
|
|
const reason = classifyAuthorityFailure(
|
|
current ? toLease(current) : null,
|
|
input.lease,
|
|
input.now,
|
|
);
|
|
if (reason === 'lease_expired' && current) {
|
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
}
|
|
await insertAudit(
|
|
tx,
|
|
rejectionAudit(
|
|
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
|
current ? toLease(current) : null,
|
|
reason,
|
|
),
|
|
);
|
|
return { ok: false, reason };
|
|
},
|
|
);
|
|
return unwrap(result);
|
|
}
|
|
|
|
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
|
|
const result: MutationResult = await this.db.transaction(
|
|
async (tx): Promise<MutationResult> => {
|
|
const updated = await tx
|
|
.update(logicalAgentConnectorLeases)
|
|
.set({
|
|
releasedAt: new Date(input.now),
|
|
expiresAt: new Date(input.now),
|
|
updatedAt: new Date(input.now),
|
|
})
|
|
.where(
|
|
and(
|
|
bindingPredicate(input.lease),
|
|
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
|
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
|
isNull(logicalAgentConnectorLeases.releasedAt),
|
|
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
|
),
|
|
)
|
|
.returning();
|
|
const row = updated[0];
|
|
if (row) {
|
|
const lease = toLease(row);
|
|
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
|
|
return { ok: true, lease };
|
|
}
|
|
const current = await findRow(tx, input.lease);
|
|
const reason = classifyAuthorityFailure(
|
|
current ? toLease(current) : null,
|
|
input.lease,
|
|
input.now,
|
|
);
|
|
if (reason === 'lease_expired' && current) {
|
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
|
}
|
|
await insertAudit(
|
|
tx,
|
|
rejectionAudit(
|
|
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
|
current ? toLease(current) : null,
|
|
reason,
|
|
),
|
|
);
|
|
return { ok: false, reason };
|
|
},
|
|
);
|
|
unwrap(result);
|
|
}
|
|
|
|
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
|
const row = await findRow(this.db, binding);
|
|
return row ? toLease(row) : null;
|
|
}
|
|
|
|
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
|
await insertAudit(this.db, event);
|
|
}
|
|
}
|
|
|
|
function unwrap(result: MutationResult): ConnectorLease {
|
|
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
|
|
return result.lease;
|
|
}
|
|
|
|
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
|
|
return `Connector lease mutation denied: ${reason}`;
|
|
}
|
|
|
|
function bindingPredicate(binding: LogicalAgentBinding) {
|
|
return and(
|
|
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
|
|
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
|
|
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
|
|
);
|
|
}
|
|
|
|
async function findRow(
|
|
db: Pick<Db, 'select'>,
|
|
binding: LogicalAgentBinding,
|
|
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
|
|
const rows = await db
|
|
.select()
|
|
.from(logicalAgentConnectorLeases)
|
|
.where(bindingPredicate(binding))
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
|
|
return Object.freeze({
|
|
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
|
|
bindingId: row.bindingId,
|
|
leaseId: row.leaseId,
|
|
connectorId: row.connectorId,
|
|
scopes: Object.freeze([...row.scopes]),
|
|
leaseEpoch: row.leaseEpoch.toString(10),
|
|
acquiredAt: row.acquiredAt.toISOString(),
|
|
heartbeatAt: row.heartbeatAt.toISOString(),
|
|
expiresAt: row.expiresAt.toISOString(),
|
|
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
|
|
});
|
|
}
|
|
|
|
function classifyAuthorityFailure(
|
|
current: ConnectorLease | null,
|
|
claimed: ConnectorLease,
|
|
now: string,
|
|
): ConnectorLeaseRejectReason {
|
|
if (!current) return 'lease_missing';
|
|
if (current.releasedAt) return 'lease_released';
|
|
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
|
|
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
|
|
return 'connector_mismatch';
|
|
}
|
|
|
|
function lifecycleAudit(
|
|
input: { readonly correlationId: string; readonly now: string },
|
|
lease: ConnectorLease,
|
|
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
|
|
): ConnectorLeaseAuditEvent {
|
|
return {
|
|
identity: lease.identity,
|
|
bindingId: lease.bindingId,
|
|
connectorId: lease.connectorId,
|
|
leaseId: lease.leaseId,
|
|
leaseEpoch: lease.leaseEpoch,
|
|
event,
|
|
outcome: 'succeeded',
|
|
correlationId: input.correlationId,
|
|
occurredAt: input.now,
|
|
};
|
|
}
|
|
|
|
function rejectionAudit(
|
|
input: {
|
|
readonly identity: ConnectorLease['identity'];
|
|
readonly bindingId: string;
|
|
readonly connectorId: string;
|
|
readonly correlationId: string;
|
|
readonly now: string;
|
|
},
|
|
current: ConnectorLease | null,
|
|
reason: ConnectorLeaseRejectReason,
|
|
): ConnectorLeaseAuditEvent {
|
|
return {
|
|
identity: input.identity,
|
|
bindingId: input.bindingId,
|
|
connectorId: input.connectorId,
|
|
event: 'reject',
|
|
outcome: 'denied',
|
|
correlationId: input.correlationId,
|
|
occurredAt: input.now,
|
|
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
|
|
reason,
|
|
};
|
|
}
|
|
|
|
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
|
|
await db.insert(connectorLeaseAuditLog).values({
|
|
tenantId: event.identity.tenantId,
|
|
logicalAgentId: event.identity.logicalAgentId,
|
|
bindingId: event.bindingId,
|
|
connectorId: event.connectorId,
|
|
...(event.leaseId ? { leaseId: event.leaseId } : {}),
|
|
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
|
|
event: event.event,
|
|
outcome: event.outcome,
|
|
...(event.reason ? { reason: event.reason } : {}),
|
|
correlationId: event.correlationId,
|
|
occurredAt: new Date(event.occurredAt),
|
|
});
|
|
}
|