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
3 changes: 3 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { SlackService } from './slack.service';
import { CoursesModule } from './courses/courses.module';
import { DataRetentionModule } from './data-retention/data-retention.module';
import { GatewayModule } from './gateway/gateway.module';
import { UsersModule } from './users/users.module';
import { NotificationsModule } from './notifications/notifications.module';
import { MessagingModule } from './messaging/messaging.module';
import { DashboardModule } from './dashboard/dashboard.module';
Expand Down Expand Up @@ -81,6 +82,8 @@ const featureFlags = loadFeatureFlags();
// ✅ API gateway: routing, rate limiting, transformation, caching
GatewayModule,

// ✅ Users module for profile and activity management
UsersModule,
NotificationsModule,
MessagingModule,
DashboardModule,
Expand Down
4 changes: 2 additions & 2 deletions src/courses/courses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ export class CoursesService {
}

const isInitiator = op.initiatedById === user.id;
const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role);
const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role.name));
if (!isInitiator && !isPrivileged) {
throw new ForbiddenOperationException('Only the initiator or an admin/moderator may undo this operation.');
}
Expand Down Expand Up @@ -400,7 +400,7 @@ export class CoursesService {
apply: (course: Course) => BulkCourseSnapshot['previous'];
}): Promise<BulkOperation> {
const { type, payload, courseIds, user, apply } = args;
const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role);
const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role.name));

const courses = await this.courseRepo.find({ where: { id: In(courseIds) } });
const found = new Map(courses.map(c => [c.id, c]));
Expand Down
2 changes: 1 addition & 1 deletion src/data-retention/data-retention.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class DataRetentionService {

const records = await repository.find({
where: {
createdAt: LessThan(cutoff),
timestamp: LessThan(cutoff),
},
take: this.configService.get<number>('retention.batchSize', 1000),
});
Expand Down
27 changes: 3 additions & 24 deletions src/database/index-optimization/index-optimization.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
resolveIndexOptimizationConfig,
Expand All @@ -11,13 +11,7 @@ import { StaleIndexService } from './services/stale-index.service';
import { IOptimizationRunSummary } from './interfaces/index-optimization.interfaces';

/**
* Orchestrates a full index-optimization cycle:
* analyse → create recommended → monitor usage → remove stale
*
* Runs on a weekly schedule when INDEX_OPT_ENABLED=true, and can be triggered
* on demand via the controller. Each stage independently respects the dry-run
* and auto-create / auto-drop flags so an operator can dial in exactly how much
* autonomy the optimizer has.
* Orchestrates a full index-optimization cycle.
*/
@Injectable()
export class IndexOptimizationService {
Expand All @@ -30,7 +24,7 @@ export class IndexOptimizationService {
private readonly creation: IndexCreationService,
private readonly usageMonitor: IndexUsageMonitorService,
private readonly staleIndex: StaleIndexService,
config?: IndexOptimizationConfig,
@Optional() config?: IndexOptimizationConfig,
) {
this.config = config ?? resolveIndexOptimizationConfig();
}
Expand All @@ -39,36 +33,27 @@ export class IndexOptimizationService {
@Cron(CronExpression.EVERY_WEEK)
async scheduledRun(): Promise<void> {
if (!this.config.enabled) {
this.logger.debug('Index optimizer disabled (INDEX_OPT_ENABLED=false)');
return;
}
this.logger.log('Starting scheduled index optimization cycle');
await this.run();
}

/**
* Execute a full cycle.
* @param force when true, applies DDL even if config is dry-run (used by the
* manual "apply" endpoint). Auto-create/auto-drop flags still gate
* destructive vs additive actions.
*/
async run(force = false): Promise<IOptimizationRunSummary> {
const startedAt = new Date().toISOString();

// 1. Query analysis → recommendations.
const recommendations = await this.analysis.analyze();

// 2. Index creation (additive). Gated by autoCreate; dry-run unless forced.
const createDryRun = force ? false : this.config.dryRun || !this.config.autoCreate;
const created = await this.creation.createFromRecommendations(
recommendations,
createDryRun,
);

// 3. Usage monitoring snapshot (read-only).
await this.usageMonitor.sample();

// 4. Stale index removal (destructive). Gated by autoDropStale.
const dropDryRun = force ? false : this.config.dryRun || !this.config.autoDropStale;
const removedStale = await this.staleIndex.removeStaleIndexes(dropDryRun);

Expand All @@ -82,15 +67,9 @@ export class IndexOptimizationService {
};

this.lastRun = summary;
this.logger.log(
`Index optimization complete: ${recommendations.length} recommendation(s), ` +
`${created.filter((c) => c.created).length} created, ` +
`${removedStale.filter((r) => r.dropped).length} stale removed`,
);
return summary;
}

/** Return the summary of the most recent run, if any. */
getLastRun(): IOptimizationRunSummary | undefined {
return this.lastRun;
}
Expand Down
24 changes: 2 additions & 22 deletions src/database/index-optimization/services/index-creation.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
Expand All @@ -12,14 +12,6 @@ import {

/**
* Applies index recommendations as real DDL.
*
* Safety properties:
* - Uses CREATE INDEX CONCURRENTLY so it never takes a long write lock.
* - Honours dry-run: when enabled, nothing is executed.
* - Caps the number of indexes created per run (maxCreatePerRun).
* - Verifies the resulting index is `valid`; a CONCURRENTLY build that fails
* leaves an INVALID index behind, which is dropped to avoid query planner
* surprises.
*/
@Injectable()
export class IndexCreationService {
Expand All @@ -28,15 +20,11 @@ export class IndexCreationService {

constructor(
@InjectDataSource() private readonly dataSource: DataSource,
config?: IndexOptimizationConfig,
@Optional() config?: IndexOptimizationConfig,
) {
this.config = config ?? resolveIndexOptimizationConfig();
}

/**
* Create indexes for the given recommendations.
* @param dryRun overrides the configured dry-run flag for this call.
*/
async createFromRecommendations(
recommendations: IIndexRecommendation[],
dryRun = this.config.dryRun,
Expand All @@ -63,12 +51,8 @@ export class IndexCreationService {
return results;
}

/** Execute a single recommendation's DDL with validity verification. */
async createOne(rec: IIndexRecommendation): Promise<IIndexCreationResult> {
try {
this.logger.log(`Creating index ${rec.suggestedName} on ${rec.table}`);
// CONCURRENTLY cannot run inside a transaction block; dataSource.query
// executes outside one by default.
await this.dataSource.query(rec.ddl);

const valid = await this.isIndexValid(rec.suggestedName);
Expand All @@ -90,9 +74,6 @@ export class IndexCreationService {
created: true,
};
} catch (err) {
this.logger.error(
`Failed to create index ${rec.suggestedName}: ${String(err)}`,
);
return {
suggestedName: rec.suggestedName,
table: rec.table,
Expand All @@ -115,7 +96,6 @@ export class IndexCreationService {
}

private async dropInvalid(indexName: string): Promise<void> {
this.logger.warn(`Dropping invalid index ${indexName}`);
await this.dataSource.query(
`DROP INDEX CONCURRENTLY IF EXISTS "${this.config.schema}"."${indexName}"`,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
Expand All @@ -8,28 +8,23 @@ import {
import { IIndexUsageStat } from '../interfaces/index-optimization.interfaces';

/**
* Reads index usage from pg_stat_user_indexes and the catalog so operators can
* see which indexes earn their keep. Also classifies each index (primary /
* unique / constraint-backed) so consumers like the stale-index detector know
* what is safe to touch.
* Reads index usage from pg_stat_user_indexes and the catalog.
*/
@Injectable()
export class IndexUsageMonitorService {
private readonly logger = new Logger(IndexUsageMonitorService.name);
private readonly config: IndexOptimizationConfig;

/** Last sampled snapshot, kept for cheap health-check reads. */
private lastSnapshot: IIndexUsageStat[] = [];
private lastSampledAt?: string;

constructor(
@InjectDataSource() private readonly dataSource: DataSource,
config?: IndexOptimizationConfig,
@Optional() config?: IndexOptimizationConfig,
) {
this.config = config ?? resolveIndexOptimizationConfig();
}

/** Fetch fresh usage stats and cache them. */
async sample(): Promise<IIndexUsageStat[]> {
const rows = await this.dataSource.query(
`SELECT s.schemaname AS schema,
Expand Down Expand Up @@ -63,11 +58,9 @@ export class IndexUsageMonitorService {

this.lastSnapshot = stats;
this.lastSampledAt = new Date().toISOString();
this.logger.debug(`Sampled usage for ${stats.length} indexes`);
return stats;
}

/** Return the cached snapshot, sampling lazily if none exists yet. */
async getSnapshot(): Promise<{
sampledAt?: string;
indexes: IIndexUsageStat[];
Expand All @@ -76,10 +69,6 @@ export class IndexUsageMonitorService {
return { sampledAt: this.lastSampledAt, indexes: this.lastSnapshot };
}

/**
* Indexes whose scan count is at or below the configured stale threshold,
* useful both for reporting and as input to stale-index removal.
*/
async findUnused(): Promise<IIndexUsageStat[]> {
const stats = await this.sample();
return stats.filter((s) => s.scans <= this.config.staleMinScans);
Expand Down
32 changes: 2 additions & 30 deletions src/database/index-optimization/services/query-analysis.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
Expand Down Expand Up @@ -36,15 +36,6 @@ interface SlowStatement {

/**
* Analyses PostgreSQL catalog and statistics to recommend indexes.
*
* Two evidence sources are combined:
* 1. Foreign-key columns lacking a supporting index — Postgres does not index
* FK columns automatically, a very common cause of slow joins/cascades.
* These give concrete, safe column recommendations.
* 2. pg_stat_user_tables sequential-scan activity — used to score and
* prioritise the above, and to flag heavily seq-scanned tables.
*
* pg_stat_statements (when installed) is surfaced for slow-query context.
*/
@Injectable()
export class QueryAnalysisService {
Expand All @@ -53,7 +44,7 @@ export class QueryAnalysisService {

constructor(
@InjectDataSource() private readonly dataSource: DataSource,
config?: IndexOptimizationConfig,
@Optional() config?: IndexOptimizationConfig,
) {
this.config = config ?? resolveIndexOptimizationConfig();
}
Expand All @@ -70,7 +61,6 @@ export class QueryAnalysisService {
const recommendations: IIndexRecommendation[] = [];

for (const fk of fkColumns) {
// Skip when an existing index already leads with these columns.
if (this.hasCoveringIndex(existingIndexes, fk.table, fk.columns)) {
continue;
}
Expand All @@ -92,21 +82,12 @@ export class QueryAnalysisService {
});
}

// Highest impact first.
recommendations.sort((a, b) => b.score - a.score);
this.logger.debug(
`Index analysis produced ${recommendations.length} recommendation(s)`,
);
return recommendations;
}

/**
* Return slow statements from pg_stat_statements for diagnostic context.
* Returns an empty array when the extension is not installed.
*/
async getSlowStatements(limit = 20): Promise<SlowStatement[]> {
if (!(await this.hasPgStatStatements())) {
this.logger.debug('pg_stat_statements not available; skipping slow-query analysis');
return [];
}
const rows = await this.query<SlowStatement>(
Expand All @@ -120,8 +101,6 @@ export class QueryAnalysisService {
return rows;
}

// ─── Catalog / stats queries ────────────────────────────────────────────

private getTableScanStats(): Promise<TableScanStat[]> {
return this.query<TableScanStat>(
`SELECT relname AS table,
Expand Down Expand Up @@ -175,9 +154,6 @@ export class QueryAnalysisService {
return Boolean(rows[0]?.exists);
}

// ─── Heuristics ─────────────────────────────────────────────────────────

/** True when an index already starts with exactly the FK column prefix. */
private hasCoveringIndex(
indexes: ExistingIndex[],
table: string,
Expand All @@ -203,7 +179,6 @@ export class QueryAnalysisService {
);
}

/** Score 0-100 weighted by seq-scan volume and table size. */
private scoreRecommendation(stat?: TableScanStat): number {
if (!stat) return 25;
const scanComponent = Math.min(
Expand All @@ -224,9 +199,6 @@ export class QueryAnalysisService {
);
}

// ─── DDL helpers ──────────────────────────────────────────────────────────

/** Deterministic, collision-resistant index name capped to Postgres' 63 chars. */
indexName(table: string, columns: string[]): string {
const raw = `idx_${table}_${columns.join('_')}`;
return raw.length <= 63 ? raw : raw.slice(0, 63);
Expand Down
Loading
Loading