Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/contrail/contrail-query.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,54 @@ describe('ContrailQueryService', () => {
}),
).resolves.not.toThrow();
});

it('should skip the COUNT query when skipCount is true', async () => {
const mockRecords = [
{
uri: 'at://did:plc:a/community.lexicon.calendar.event/1',
did: 'did:plc:a',
},
{
uri: 'at://did:plc:b/community.lexicon.calendar.event/2',
did: 'did:plc:b',
},
];
mockDataSource.query.mockResolvedValueOnce(mockRecords);

const result = await service.find('community.lexicon.calendar.event', {
skipCount: true,
limit: 50,
});

// Only one query should have been executed (the SELECT, not the COUNT)
expect(mockDataSource.query).toHaveBeenCalledTimes(1);
expect(result.records).toEqual(mockRecords);
expect(result.total).toBe(-1);
});

it('should still run COUNT query when skipCount is false', async () => {
mockDataSource.query
.mockResolvedValueOnce([{ total: '5' }])
.mockResolvedValueOnce([{ uri: 'at://test', did: 'did:plc:a' }]);

const result = await service.find('community.lexicon.calendar.event', {
skipCount: false,
});

expect(mockDataSource.query).toHaveBeenCalledTimes(2);
expect(result.total).toBe(5);
});

it('should run COUNT query when skipCount is not specified', async () => {
mockDataSource.query
.mockResolvedValueOnce([{ total: '3' }])
.mockResolvedValueOnce([]);

const result = await service.find('community.lexicon.calendar.event');

expect(mockDataSource.query).toHaveBeenCalledTimes(2);
expect(result.total).toBe(3);
});
});

describe('findWithGeoFilter', () => {
Expand Down
14 changes: 9 additions & 5 deletions src/contrail/contrail-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class ContrailQueryService {
orderBy?: string;
limit?: number;
offset?: number;
skipCount?: boolean;
} = {},
): Promise<{ records: ContrailRecord<T>[]; total: number }> {
if (options.orderBy) {
Expand All @@ -89,11 +90,14 @@ export class ContrailQueryService {

const where = sqlParts.length > 0 ? `WHERE ${sqlParts.join(' AND ')}` : '';

const countResult = await ds.query(
`SELECT count(*) as total FROM ${table} ${where}`,
allParams,
);
const total = parseInt(countResult[0]?.total ?? '0', 10);
let total = -1;
if (!options.skipCount) {
const countResult = await ds.query(
`SELECT count(*) as total FROM ${table} ${where}`,
allParams,
);
total = parseInt(countResult[0]?.total ?? '0', 10);
}

const orderClause = options.orderBy ? `ORDER BY ${options.orderBy}` : '';

Expand Down
3 changes: 3 additions & 0 deletions src/database/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ function patchDriverForMetrics(dataSource: DataSource, tenantId: string): void {
operation,
duration,
'success',
queryFingerprint,
);
}

Expand All @@ -182,6 +183,7 @@ function patchDriverForMetrics(dataSource: DataSource, tenantId: string): void {
operation,
duration,
'error',
queryFingerprint,
);
}

Expand All @@ -203,6 +205,7 @@ function patchDriverForMetrics(dataSource: DataSource, tenantId: string): void {
operation,
duration,
'success',
queryFingerprint,
);
}
span.setAttribute('db.duration_ms', duration);
Expand Down
2 changes: 1 addition & 1 deletion src/database/database-metrics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const databaseMetricsProviders = [
makeHistogramProvider({
name: 'db_query_duration_seconds',
help: 'Database query duration in seconds',
labelNames: ['tenant', 'operation', 'status'],
labelNames: ['tenant', 'operation', 'status', 'fingerprint'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2.5, 5, 10],
}),
makeCounterProvider({
Expand Down
131 changes: 131 additions & 0 deletions src/database/database-metrics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DatabaseMetricsService } from './database-metrics.service';
import { Histogram, Counter } from 'prom-client';

describe('DatabaseMetricsService', () => {
let service: DatabaseMetricsService;
let mockQueryDurationHistogram: jest.Mocked<Histogram<string>>;
let mockQueriesCounter: jest.Mocked<Counter<string>>;

beforeEach(async () => {
mockQueryDurationHistogram = {
observe: jest.fn(),
} as any;

mockQueriesCounter = {
inc: jest.fn(),
} as any;

const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMetricsService,
{
provide: 'PROM_METRIC_DB_POOL_SIZE',
useValue: { set: jest.fn() },
},
{
provide: 'PROM_METRIC_DB_POOL_IDLE',
useValue: { set: jest.fn() },
},
{
provide: 'PROM_METRIC_DB_POOL_WAITING',
useValue: { set: jest.fn() },
},
{
provide: 'PROM_METRIC_DB_ACTIVE_CONNECTIONS',
useValue: { set: jest.fn() },
},
{
provide: 'PROM_METRIC_DB_QUERY_DURATION_SECONDS',
useValue: mockQueryDurationHistogram,
},
{
provide: 'PROM_METRIC_DB_CONNECTION_ERRORS_TOTAL',
useValue: { inc: jest.fn() },
},
{
provide: 'PROM_METRIC_DB_QUERIES_TOTAL',
useValue: mockQueriesCounter,
},
{
provide: 'PROM_METRIC_DB_CONNECTION_ACQUISITION_DURATION_SECONDS',
useValue: { observe: jest.fn() },
},
],
}).compile();

service = module.get<DatabaseMetricsService>(DatabaseMetricsService);
});

describe('recordQueryDuration', () => {
it('should pass fingerprint label to histogram observe', () => {
service.recordQueryDuration(
'tenant1',
'SELECT',
50,
'success',
'abc123def456',
);

expect(mockQueryDurationHistogram.observe).toHaveBeenCalledWith(
{
tenant: 'tenant1',
operation: 'SELECT',
status: 'success',
fingerprint: 'abc123def456',
},
0.05,
);
});

it('should default fingerprint to "unknown" when not provided', () => {
service.recordQueryDuration('tenant1', 'INSERT', 100, 'success');

expect(mockQueryDurationHistogram.observe).toHaveBeenCalledWith(
{
tenant: 'tenant1',
operation: 'INSERT',
status: 'success',
fingerprint: 'unknown',
},
0.1,
);
});

it('should include fingerprint in slow query warning log', () => {
const warnSpy = jest.spyOn(service['logger'], 'warn');

service.recordQueryDuration(
'tenant1',
'SELECT',
1500,
'success',
'slowquery1234',
);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('slowquery1234'),
);
});

it('should pass fingerprint with error status', () => {
service.recordQueryDuration(
'tenant1',
'UPDATE',
200,
'error',
'errfp123456',
);

expect(mockQueryDurationHistogram.observe).toHaveBeenCalledWith(
{
tenant: 'tenant1',
operation: 'UPDATE',
status: 'error',
fingerprint: 'errfp123456',
},
0.2,
);
});
});
});
10 changes: 8 additions & 2 deletions src/database/database-metrics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,18 @@ export class DatabaseMetricsService implements OnModuleInit {
operation: string,
durationMs: number,
status: 'success' | 'error' = 'success',
fingerprint?: string,
): void {
try {
const durationSeconds = durationMs / 1000;

this.queryDurationHistogram.observe(
{ tenant: tenantId, operation, status },
{
tenant: tenantId,
operation,
status,
fingerprint: fingerprint || 'unknown',
},
durationSeconds,
);

Expand All @@ -138,7 +144,7 @@ export class DatabaseMetricsService implements OnModuleInit {
// Warn on slow queries (> 1 second)
if (durationMs > 1000) {
this.logger.warn(
`Slow query detected for tenant ${tenantId} (${operation}, ${status}): ${durationMs}ms`,
`Slow query detected for tenant ${tenantId} (${operation}, ${status}, fingerprint=${fingerprint || 'unknown'}): ${durationMs}ms`,
);
}
} catch (error) {
Expand Down
22 changes: 22 additions & 0 deletions src/event/services/event-query.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,28 @@ describe('EventQueryService', () => {
// 3. Result contains at most 4 events
expect(result.length).toBeLessThanOrEqual(4);
});

it('should pass skipCount: true to contrail find (no COUNT query needed for random sampling)', async () => {
const contrailService = service[
'contrailQueryService'
] as jest.Mocked<ContrailQueryService>;
const enrichmentService = service[
'atprotoEnrichmentService'
] as jest.Mocked<AtprotoEnrichmentService>;

contrailService.find.mockResolvedValueOnce({
records: [] as any,
total: -1,
});
enrichmentService.enrichRecords.mockResolvedValueOnce([]);

await service.getHomePageFeaturedEvents();

expect(contrailService.find).toHaveBeenCalledWith(
'community.lexicon.calendar.event',
expect.objectContaining({ skipCount: true }),
);
});
});

describe('getHomePageUserNextHostedEvent', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/event/services/event-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ export class EventQueryService {
const now = new Date().toISOString();

// Fetch upcoming public events from Contrail (larger window for random sampling)
// skipCount: true — we only need records for random sampling, not the total count
const contrailResult = await this.contrailQueryService.find<CalendarEvent>(
'community.lexicon.calendar.event',
{
Expand All @@ -990,6 +991,7 @@ export class EventQueryService {
orderBy: `record->>'startsAt' ASC, uri ASC`,
limit: 50,
offset: 0,
skipCount: true,
},
);

Expand Down
Loading
Loading