refactor: move distributed locks to cosmos-first repository
This commit is contained in:
parent
733874bb6d
commit
5c4c001f35
186
backend/src/services/distributedLockRepository.ts
Normal file
186
backend/src/services/distributedLockRepository.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { getContainer } from '@bytelyst/cosmos';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const CONTAINER_NAME = 'runtime_locks';
|
||||||
|
|
||||||
|
type LockScope = 'entry' | 'reconciliation';
|
||||||
|
|
||||||
|
interface RuntimeLockDocument {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
scope: LockScope;
|
||||||
|
profileId: string;
|
||||||
|
symbol?: string;
|
||||||
|
owner: string;
|
||||||
|
expiresAt: number;
|
||||||
|
updatedAt: string;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCosmosConfigured(): boolean {
|
||||||
|
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntryLockId(profileId: string, symbol?: string): string {
|
||||||
|
return `entry:${profileId}:${String(symbol || '').trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReconciliationLockId(profileId: string): string {
|
||||||
|
return `reconciliation:${profileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLock(id: string): Promise<RuntimeLockDocument | null> {
|
||||||
|
const container = getContainer(CONTAINER_NAME);
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(id, config.PRODUCT_ID).read<RuntimeLockDocument>();
|
||||||
|
return resource || null;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: number })?.code;
|
||||||
|
if (code === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLock(doc: RuntimeLockDocument): Promise<boolean> {
|
||||||
|
const container = getContainer(CONTAINER_NAME);
|
||||||
|
await container.items.upsert(doc);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLock(id: string): Promise<boolean> {
|
||||||
|
const container = getContainer(CONTAINER_NAME);
|
||||||
|
try {
|
||||||
|
await container.item(id, config.PRODUCT_ID).delete();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: number })?.code;
|
||||||
|
if (code === 404) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(doc: RuntimeLockDocument | null | undefined): boolean {
|
||||||
|
return Boolean(doc && Number(doc.expiresAt) > Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryAcquireEntryLock(
|
||||||
|
profileId: string,
|
||||||
|
symbol: string | undefined,
|
||||||
|
owner: string,
|
||||||
|
ttlSeconds: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
if (!isCosmosConfigured()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = buildEntryLockId(profileId, symbol);
|
||||||
|
const existing = await readLock(id);
|
||||||
|
if (isActive(existing) && existing?.owner !== owner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeLock({
|
||||||
|
id,
|
||||||
|
productId: config.PRODUCT_ID,
|
||||||
|
scope: 'entry',
|
||||||
|
profileId,
|
||||||
|
symbol: String(symbol || '').trim() || undefined,
|
||||||
|
owner,
|
||||||
|
expiresAt: Date.now() + Math.max(1, ttlSeconds) * 1000,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
ttl: Math.max(1, ttlSeconds)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DistributedLockRepo] Cosmos entry-lock acquire failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseEntryLock(
|
||||||
|
profileId: string,
|
||||||
|
symbol: string | undefined,
|
||||||
|
owner: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
if (!isCosmosConfigured()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = buildEntryLockId(profileId, symbol);
|
||||||
|
const existing = await readLock(id);
|
||||||
|
if (!existing) return false;
|
||||||
|
if (existing.owner !== owner) return false;
|
||||||
|
return await deleteLock(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DistributedLockRepo] Cosmos entry-lock release failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryAcquireReconciliationLock(
|
||||||
|
profileId: string,
|
||||||
|
owner: string,
|
||||||
|
ttlSeconds: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
if (!isCosmosConfigured()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = buildReconciliationLockId(profileId);
|
||||||
|
const existing = await readLock(id);
|
||||||
|
if (isActive(existing) && existing?.owner !== owner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeLock({
|
||||||
|
id,
|
||||||
|
productId: config.PRODUCT_ID,
|
||||||
|
scope: 'reconciliation',
|
||||||
|
profileId,
|
||||||
|
owner,
|
||||||
|
expiresAt: Date.now() + Math.max(1, ttlSeconds) * 1000,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
ttl: Math.max(1, ttlSeconds)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DistributedLockRepo] Cosmos reconciliation-lock acquire failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseReconciliationLock(
|
||||||
|
profileId: string,
|
||||||
|
owner: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
if (!isCosmosConfigured()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = buildReconciliationLockId(profileId);
|
||||||
|
const existing = await readLock(id);
|
||||||
|
if (!existing) return false;
|
||||||
|
if (existing.owner !== owner) return false;
|
||||||
|
return await deleteLock(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DistributedLockRepo] Cosmos reconciliation-lock release failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isEntryLockActive(
|
||||||
|
profileId: string,
|
||||||
|
symbol: string | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!profileId) return false;
|
||||||
|
if (!isCosmosConfigured()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await readLock(buildEntryLockId(profileId, symbol));
|
||||||
|
return isActive(existing);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DistributedLockRepo] Cosmos entry-lock status failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import logger from '../utils/logger.js';
|
|||||||
import { supabaseService } from './SupabaseService.js';
|
import { supabaseService } from './SupabaseService.js';
|
||||||
import { healthTracker } from './healthTracker.js';
|
import { healthTracker } from './healthTracker.js';
|
||||||
import { observabilityService } from './observabilityService.js';
|
import { observabilityService } from './observabilityService.js';
|
||||||
|
import * as distributedLockRepository from './distributedLockRepository.js';
|
||||||
|
|
||||||
const normalizeSymbol = (symbol?: string): string => {
|
const normalizeSymbol = (symbol?: string): string => {
|
||||||
return String(symbol || '').trim();
|
return String(symbol || '').trim();
|
||||||
@ -9,6 +10,15 @@ const normalizeSymbol = (symbol?: string): string => {
|
|||||||
|
|
||||||
export class DistributedLockService {
|
export class DistributedLockService {
|
||||||
async tryAcquireRowLock(profileId: string, symbol: string | undefined, owner: string, ttlSeconds: number = 30): Promise<boolean> {
|
async tryAcquireRowLock(profileId: string, symbol: string | undefined, owner: string, ttlSeconds: number = 30): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
const cosmosAcquired = await distributedLockRepository.tryAcquireEntryLock(profileId, symbol, owner, ttlSeconds);
|
||||||
|
if (cosmosAcquired) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.tryAcquireRowLockLegacy(profileId, symbol, owner, ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAcquireRowLockLegacy(profileId: string, symbol: string | undefined, owner: string, ttlSeconds: number = 30): Promise<boolean> {
|
||||||
if (!profileId || !owner) return false;
|
if (!profileId || !owner) return false;
|
||||||
const normalizedSymbol = normalizeSymbol(symbol);
|
const normalizedSymbol = normalizeSymbol(symbol);
|
||||||
const client = supabaseService.getClient();
|
const client = supabaseService.getClient();
|
||||||
@ -41,6 +51,15 @@ export class DistributedLockService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async releaseRowLock(profileId: string, symbol: string | undefined, owner: string): Promise<boolean> {
|
async releaseRowLock(profileId: string, symbol: string | undefined, owner: string): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
const cosmosReleased = await distributedLockRepository.releaseEntryLock(profileId, symbol, owner);
|
||||||
|
if (cosmosReleased) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.releaseRowLockLegacy(profileId, symbol, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseRowLockLegacy(profileId: string, symbol: string | undefined, owner: string): Promise<boolean> {
|
||||||
if (!profileId || !owner) return false;
|
if (!profileId || !owner) return false;
|
||||||
const normalizedSymbol = normalizeSymbol(symbol);
|
const normalizedSymbol = normalizeSymbol(symbol);
|
||||||
const client = supabaseService.getClient();
|
const client = supabaseService.getClient();
|
||||||
@ -68,6 +87,15 @@ export class DistributedLockService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async tryAcquireReconciliationLock(profileId: string, owner: string, ttlSeconds: number = 30): Promise<boolean> {
|
async tryAcquireReconciliationLock(profileId: string, owner: string, ttlSeconds: number = 30): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
const cosmosAcquired = await distributedLockRepository.tryAcquireReconciliationLock(profileId, owner, ttlSeconds);
|
||||||
|
if (cosmosAcquired) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.tryAcquireReconciliationLockLegacy(profileId, owner, ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAcquireReconciliationLockLegacy(profileId: string, owner: string, ttlSeconds: number = 30): Promise<boolean> {
|
||||||
if (!profileId || !owner) return false;
|
if (!profileId || !owner) return false;
|
||||||
const client = supabaseService.getClient();
|
const client = supabaseService.getClient();
|
||||||
if (!client) return false;
|
if (!client) return false;
|
||||||
@ -94,6 +122,15 @@ export class DistributedLockService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async releaseReconciliationLock(profileId: string, owner: string): Promise<boolean> {
|
async releaseReconciliationLock(profileId: string, owner: string): Promise<boolean> {
|
||||||
|
if (!profileId || !owner) return false;
|
||||||
|
const cosmosReleased = await distributedLockRepository.releaseReconciliationLock(profileId, owner);
|
||||||
|
if (cosmosReleased) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.releaseReconciliationLockLegacy(profileId, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseReconciliationLockLegacy(profileId: string, owner: string): Promise<boolean> {
|
||||||
if (!profileId || !owner) return false;
|
if (!profileId || !owner) return false;
|
||||||
const client = supabaseService.getClient();
|
const client = supabaseService.getClient();
|
||||||
if (!client) return false;
|
if (!client) return false;
|
||||||
@ -114,6 +151,15 @@ export class DistributedLockService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async isEntryInProgress(profileId: string, symbol?: string): Promise<boolean> {
|
async isEntryInProgress(profileId: string, symbol?: string): Promise<boolean> {
|
||||||
|
if (!profileId) return false;
|
||||||
|
const cosmosState = await distributedLockRepository.isEntryLockActive(profileId, symbol);
|
||||||
|
if (cosmosState) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.isEntryInProgressLegacy(profileId, symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isEntryInProgressLegacy(profileId: string, symbol?: string): Promise<boolean> {
|
||||||
if (!profileId) return false;
|
if (!profileId) return false;
|
||||||
const normalizedSymbol = normalizeSymbol(symbol);
|
const normalizedSymbol = normalizeSymbol(symbol);
|
||||||
const client = supabaseService.getClient();
|
const client = supabaseService.getClient();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user