45 KiB
45 KiB
Backend Implementation Guide: Document Numbering
Version: 1.0.0 Last Updated: 2025-01-16 Status: APPROVED Related: Requirements, ADR-018
1. Architecture Overview
1.1 Module Structure
backend/src/modules/document-numbering/
├── document-numbering.module.ts
├── controllers/
│ ├── numbering.controller.ts
│ ├── numbering-admin.controller.ts
│ └── numbering-metrics.controller.ts
├── services/
│ ├── sequence.service.ts
│ ├── reservation.service.ts
│ ├── manual-override.service.ts
│ ├── void-replace.service.ts
│ ├── format.service.ts
│ ├── metrics.service.ts
│ └── migration.service.ts
├── entities/
│ ├── numbering-config.entity.ts
│ ├── numbering-sequence.entity.ts
│ ├── numbering-audit-log.entity.ts
│ └── numbering-reservation.entity.ts
├── dto/
│ ├── reserve-number.dto.ts
│ ├── confirm-reservation.dto.ts
│ ├── manual-override.dto.ts
│ ├── void-document.dto.ts
│ └── bulk-import.dto.ts
├── guards/
│ └── manual-override.guard.ts
├── decorators/
│ └── audit-numbering.decorator.ts
├── interfaces/
│ └── numbering.interface.ts
└── tests/
├── unit/
├── integration/
└── e2e/
2. Core Entities
2.1 Numbering Configuration
// entities/numbering-config.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
@Entity('document_numbering_configs')
export class NumberingConfig {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
document_type: string;
@Column({ length: 200 })
format: string;
@Column({
type: 'enum',
enum: ['GLOBAL', 'PROJECT', 'CONTRACT', 'YEARLY', 'MONTHLY'],
default: 'GLOBAL'
})
scope: string;
@Column({ default: false })
allow_manual_override: boolean;
@Column({ default: 999999 })
max_value: number;
@Column({ type: 'json', nullable: true })
metadata: Record<string, any>;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
created_at: Date;
@Column({
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP'
})
updated_at: Date;
@OneToMany(() => NumberingSequence, sequence => sequence.config)
sequences: NumberingSequence[];
}
2.2 Sequence Counter
// entities/numbering-sequence.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm';
@Entity('document_numbering_sequences')
export class NumberingSequence {
@PrimaryGeneratedColumn()
id: number;
@Column()
config_id: number;
@Column({ length: 50, nullable: true })
scope_value: string; // project_id, contract_id, year, etc.
@Column({ default: 0 })
current_value: number;
@Column({ type: 'timestamp', nullable: true })
last_used_at: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
created_at: Date;
@ManyToOne(() => NumberingConfig, config => config.sequences)
@JoinColumn({ name: 'config_id' })
config: NumberingConfig;
// Composite unique constraint
@Index(['config_id', 'scope_value'], { unique: true })
}
2.3 Audit Log
// entities/numbering-audit-log.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('document_numbering_audit_logs')
export class NumberingAuditLog {
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
id: bigint;
@Column({ length: 50 })
@Index()
operation: string; // RESERVE, CONFIRM, CANCEL, MANUAL_OVERRIDE, VOID
@Column({ length: 50, nullable: true })
document_type: string;
@Column({ length: 50, nullable: true })
@Index()
document_number: string;
@Column({ type: 'text', nullable: true })
old_value: string;
@Column({ type: 'text', nullable: true })
new_value: string;
@Column()
@Index()
user_id: number;
@Column({ length: 45, nullable: true })
ip_address: string;
@Column({ length: 500, nullable: true })
user_agent: string;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
@Index()
timestamp: Date;
@Column({ type: 'json', nullable: true })
metadata: Record<string, any>;
}
3. Core Services
3.1 Sequence Service
// services/sequence.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Redlock } from 'redlock';
import { NumberingConfig, NumberingSequence } from '../entities';
import { FormatService } from './format.service';
@Injectable()
export class SequenceService {
private readonly logger = new Logger(SequenceService.name);
constructor(
@InjectRepository(NumberingConfig)
private configRepo: Repository<NumberingConfig>,
@InjectRepository(NumberingSequence)
private sequenceRepo: Repository<NumberingSequence>,
private dataSource: DataSource,
private redlock: Redlock,
private formatService: FormatService,
) {}
/**
* Get next sequence number with distributed locking
*/
async getNextSequence(
documentType: string,
scopeValue?: string,
): Promise<string> {
// 1. Get configuration
const config = await this.getConfig(documentType);
// 2. Build lock key
const lockKey = this.buildLockKey(documentType, scopeValue);
// 3. Try with Redlock first
try {
return await this.getSequenceWithRedlock(config, scopeValue, lockKey);
} catch (error) {
if (this.isRedisUnavailable(error)) {
// Fallback to database-only mode
this.logger.warn('Redis unavailable, using DB-only mode');
return await this.getSequenceWithDbLock(config, scopeValue);
}
throw error;
}
}
/**
* Get sequence with Redlock + Database pessimistic lock
*/
private async getSequenceWithRedlock(
config: NumberingConfig,
scopeValue: string,
lockKey: string,
): Promise<string> {
// Acquire distributed lock
const lock = await this.redlock.acquire([lockKey], 5000, {
retryCount: 3,
retryDelay: 200,
retryJitter: 100,
});
try {
return await this.dataSource.transaction(async (manager) => {
// Get or create sequence with pessimistic lock
let sequence = await manager.findOne(NumberingSequence, {
where: {
config_id: config.id,
scope_value: scopeValue || null,
},
lock: { mode: 'pessimistic_write' },
});
if (!sequence) {
sequence = await this.createSequence(manager, config, scopeValue);
}
// Increment sequence
const nextValue = await this.incrementSequence(
manager,
sequence,
config,
);
// Format number
return this.formatService.formatNumber(config.format, nextValue, {
documentType: config.document_type,
scopeValue,
});
});
} finally {
// Always release lock
await lock.release();
}
}
/**
* Fallback: Database-only locking (no Redis)
*/
private async getSequenceWithDbLock(
config: NumberingConfig,
scopeValue: string,
): Promise<string> {
return await this.dataSource.transaction(async (manager) => {
let sequence = await manager.findOne(NumberingSequence, {
where: {
config_id: config.id,
scope_value: scopeValue || null,
},
lock: { mode: 'pessimistic_write' },
});
if (!sequence) {
sequence = await this.createSequence(manager, config, scopeValue);
}
const nextValue = await this.incrementSequence(
manager,
sequence,
config,
);
return this.formatService.formatNumber(config.format, nextValue, {
documentType: config.document_type,
scopeValue,
});
});
}
/**
* Increment sequence, skip cancelled numbers
*/
private async incrementSequence(
manager: EntityManager,
sequence: NumberingSequence,
config: NumberingConfig,
): Promise<number> {
let nextValue = sequence.current_value + 1;
// Skip cancelled numbers
while (await this.isCancelledNumber(manager, config, nextValue)) {
this.logger.debug(`Skipping cancelled number: ${nextValue}`);
nextValue++;
}
// Check max value
if (nextValue > config.max_value) {
throw new SequenceExhaustedError(
`Sequence exhausted for ${config.document_type}. Max: ${config.max_value}`,
);
}
// Update sequence
sequence.current_value = nextValue;
sequence.last_used_at = new Date();
await manager.save(sequence);
return nextValue;
}
/**
* Check if number is cancelled
*/
private async isCancelledNumber(
manager: EntityManager,
config: NumberingConfig,
value: number,
): Promise<boolean> {
const count = await manager.count(NumberingAuditLog, {
where: {
document_type: config.document_type,
operation: 'CANCEL',
metadata: { sequence_value: value },
},
});
return count > 0;
}
/**
* Create new sequence
*/
private async createSequence(
manager: EntityManager,
config: NumberingConfig,
scopeValue: string,
): Promise<NumberingSequence> {
const sequence = manager.create(NumberingSequence, {
config_id: config.id,
scope_value: scopeValue || null,
current_value: 0,
});
return await manager.save(sequence);
}
/**
* Build lock key for Redlock
*/
private buildLockKey(documentType: string, scopeValue?: string): string {
const parts = ['numbering', documentType];
if (scopeValue) parts.push(scopeValue);
return parts.join(':');
}
/**
* Check if error is Redis unavailable
*/
private isRedisUnavailable(error: any): boolean {
return error.message?.includes('Redis') ||
error.message?.includes('ECONNREFUSED');
}
/**
* Get configuration (cached)
*/
@Cacheable({ ttl: 3600, key: 'numbering:config:{documentType}' })
private async getConfig(documentType: string): Promise<NumberingConfig> {
const config = await this.configRepo.findOne({
where: { document_type: documentType },
});
if (!config) {
throw new ConfigNotFoundError(
`Numbering config not found for ${documentType}`,
);
}
return config;
}
}
3.2 Reservation Service
// services/reservation.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Redis } from 'ioredis';
import { v4 as uuidv4 } from 'uuid';
import { SequenceService } from './sequence.service';
import { AuditService } from './audit.service';
interface Reservation {
token: string;
document_number: string;
document_type: string;
scope_value?: string;
expires_at: Date;
metadata?: Record<string, any>;
}
@Injectable()
export class ReservationService {
private readonly logger = new Logger(ReservationService.name);
private readonly TTL = 300; // 5 minutes
constructor(
private redis: Redis,
private sequenceService: SequenceService,
private auditService: AuditService,
) {}
/**
* Reserve a document number
*/
async reserve(
documentType: string,
scopeValue?: string,
metadata?: Record<string, any>,
): Promise<Reservation> {
// 1. Generate next number
const documentNumber = await this.sequenceService.getNextSequence(
documentType,
scopeValue,
);
// 2. Generate reservation token
const token = uuidv4();
// 3. Calculate expiry
const expiresAt = new Date(Date.now() + this.TTL * 1000);
// 4. Save reservation to Redis
const reservation: Reservation = {
token,
document_number: documentNumber,
document_type: documentType,
scope_value: scopeValue,
expires_at: expiresAt,
metadata,
};
await this.redis.setex(
`reservation:${token}`,
this.TTL,
JSON.stringify(reservation),
);
// 5. Audit log
await this.auditService.log({
operation: 'RESERVE',
document_type: documentType,
document_number: documentNumber,
metadata: { token, scope_value: scopeValue },
});
this.logger.log(`Reserved number: ${documentNumber}, token: ${token}`);
return reservation;
}
/**
* Confirm reservation
*/
async confirm(token: string, userId: number): Promise<string> {
// 1. Get reservation from Redis
const reservation = await this.getReservation(token);
if (!reservation) {
throw new ReservationExpiredError(
'Reservation not found or expired. Please reserve a new number.',
);
}
// 2. Save to database (via document creation)
// Note: Actual document creation happens in the calling service
// 3. Delete reservation from Redis
await this.redis.del(`reservation:${token}`);
// 4. Audit log
await this.auditService.log({
operation: 'CONFIRM',
document_type: reservation.document_type,
document_number: reservation.document_number,
user_id: userId,
metadata: { token },
});
this.logger.log(`Confirmed reservation: ${reservation.document_number}`);
return reservation.document_number;
}
/**
* Cancel reservation
*/
async cancel(token: string, userId: number): Promise<void> {
const reservation = await this.getReservation(token);
if (reservation) {
// Delete reservation
await this.redis.del(`reservation:${token}`);
// Audit log
await this.auditService.log({
operation: 'CANCEL',
document_type: reservation.document_type,
document_number: reservation.document_number,
user_id: userId,
metadata: { token },
});
this.logger.log(`Cancelled reservation: ${reservation.document_number}`);
}
}
/**
* Get reservation from Redis
*/
private async getReservation(token: string): Promise<Reservation | null> {
const data = await this.redis.get(`reservation:${token}`);
return data ? JSON.parse(data) : null;
}
/**
* Cleanup expired reservations (scheduled job)
*/
@Cron('0 */5 * * * *') // Every 5 minutes
async cleanupExpired(): Promise<void> {
const keys = await this.redis.keys('reservation:*');
for (const key of keys) {
const ttl = await this.redis.ttl(key);
if (ttl <= 0) {
await this.redis.del(key);
this.logger.debug(`Cleaned up expired reservation: ${key}`);
}
}
}
}
3.3 Manual Override Service
// services/manual-override.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { NumberingConfig, NumberingSequence } from '../entities';
import { FormatService } from './format.service';
import { AuditService } from './audit.service';
@Injectable()
export class ManualOverrideService {
private readonly logger = new Logger(ManualOverrideService.name);
constructor(
@InjectRepository(NumberingConfig)
private configRepo: Repository<NumberingConfig>,
@InjectRepository(NumberingSequence)
private sequenceRepo: Repository<NumberingSequence>,
private dataSource: DataSource,
private formatService: FormatService,
private auditService: AuditService,
) {}
/**
* Create document with manual number
*/
async createWithManualNumber(
documentType: string,
manualNumber: string,
userId: number,
reason: string,
skipValidation = false,
): Promise<void> {
// 1. Get configuration
const config = await this.configRepo.findOne({
where: { document_type: documentType },
});
if (!config) {
throw new ConfigNotFoundError(`Config not found for ${documentType}`);
}
if (!config.allow_manual_override) {
throw new ManualOverrideNotAllowedError(
`Manual override not allowed for ${documentType}`,
);
}
// 2. Validate
if (!skipValidation) {
await this.validate(manualNumber, config);
}
// 3. Check duplicate
const exists = await this.checkDuplicate(manualNumber);
if (exists) {
throw new DuplicateNumberError(
`Number ${manualNumber} already exists`,
);
}
// 4. Update sequence if higher
await this.updateSequenceIfHigher(
documentType,
manualNumber,
config,
);
// 5. Audit log
await this.auditService.log({
operation: 'MANUAL_OVERRIDE',
document_type: documentType,
document_number: manualNumber,
user_id: userId,
metadata: { reason, skip_validation: skipValidation },
});
this.logger.log(`Manual override: ${manualNumber} by user ${userId}`);
}
/**
* Validate manual number format
*/
private async validate(
manualNumber: string,
config: NumberingConfig,
): Promise<void> {
const isValid = this.formatService.matchesFormat(
manualNumber,
config.format,
);
if (!isValid) {
throw new InvalidFormatError(
`Number ${manualNumber} does not match format ${config.format}`,
);
}
}
/**
* Check if number already exists
*/
private async checkDuplicate(number: string): Promise<boolean> {
// Check in your document tables
// This is a placeholder - implement based on your schema
const count = await this.dataSource.query(
`
SELECT COUNT(*) as count FROM (
SELECT document_number FROM correspondences WHERE document_number = ?
UNION ALL
SELECT document_number FROM rfas WHERE document_number = ?
UNION ALL
SELECT document_number FROM drawings WHERE document_number = ?
) AS all_docs
`,
[number, number, number],
);
return count[0].count > 0;
}
/**
* Update sequence counter if manual number is higher
*/
private async updateSequenceIfHigher(
documentType: string,
manualNumber: string,
config: NumberingConfig,
): Promise<void> {
// Extract sequence value from manual number
const sequenceValue = this.formatService.extractSequence(
manualNumber,
config.format,
);
if (!sequenceValue) {
this.logger.warn(`Could not extract sequence from ${manualNumber}`);
return;
}
// Update sequence if higher
await this.dataSource.transaction(async (manager) => {
const sequence = await manager.findOne(NumberingSequence, {
where: { config_id: config.id },
lock: { mode: 'pessimistic_write' },
});
if (sequence && sequenceValue > sequence.current_value) {
sequence.current_value = sequenceValue;
sequence.last_used_at = new Date();
await manager.save(sequence);
this.logger.log(
`Updated sequence for ${documentType} to ${sequenceValue}`,
);
}
});
}
}
4. Controllers
4.1 Main Numbering Controller
// controllers/numbering.controller.ts
import {
Controller, Post, Body, UseGuards,
HttpCode, HttpStatus
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards';
import { CurrentUser } from '../../common/decorators';
import { ReservationService } from '../services';
import { ReserveNumberDto, ConfirmReservationDto } from '../dto';
@ApiTags('Document Numbering')
@ApiBearerAuth()
@Controller('document-numbering')
@UseGuards(JwtAuthGuard)
export class NumberingController {
constructor(
private reservationService: ReservationService,
) {}
@Post('reserve')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Reserve a document number' })
async reserve(
@Body() dto: ReserveNumberDto,
@CurrentUser() user: any,
) {
const reservation = await this.reservationService.reserve(
dto.document_type,
dto.scope_value,
dto.metadata,
);
return {
success: true,
data: reservation,
};
}
@Post('confirm')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Confirm a reservation' })
async confirm(
@Body() dto: ConfirmReservationDto,
@CurrentUser() user: any,
) {
const documentNumber = await this.reservationService.confirm(
dto.token,
user.id,
);
return {
success: true,
data: { document_number: documentNumber },
};
}
@Post('cancel')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel a reservation' })
async cancel(
@Body() dto: ConfirmReservationDto,
@CurrentUser() user: any,
) {
await this.reservationService.cancel(dto.token, user.id);
return {
success: true,
message: 'Reservation cancelled',
};
}
}
5. Integration with Document Creation
5.1 Correspondence Example
// modules/correspondence/services/correspondence.service.ts
@Injectable()
export class CorrespondenceService {
constructor(
private reservationService: ReservationService,
private dataSource: DataSource,
) {}
async create(dto: CreateCorrespondenceDto, userId: number) {
// Phase 1: Reserve number
const { token, document_number } = await this.reservationService.reserve(
'COR',
dto.project_id.toString(),
);
try {
// Phase 2: Create document in transaction
const correspondence = await this.dataSource.transaction(
async (manager) => {
// Create correspondence
const corr = manager.create(Correspondence, {
document_number,
...dto,
created_by: userId,
});
await manager.save(corr);
// Confirm reservation
await this.reservationService.confirm(token, userId);
return corr;
},
);
return correspondence;
} catch (error) {
// Phase 2 failed: Cancel reservation
await this.reservationService.cancel(token, userId);
throw error;
}
}
}
6. Testing
6.1 Unit Tests
// tests/unit/sequence.service.spec.ts
describe('SequenceService', () => {
let service: SequenceService;
let configRepo: Repository<NumberingConfig>;
let sequenceRepo: Repository<NumberingSequence>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
SequenceService,
{ provide: getRepositoryToken(NumberingConfig), useValue: mockRepo },
{ provide: getRepositoryToken(NumberingSequence), useValue: mockRepo },
{ provide: DataSource, useValue: mockDataSource },
{ provide: Redlock, useValue: mockRedlock },
{ provide: FormatService, useValue: mockFormatService },
],
}).compile();
service = module.get<SequenceService>(SequenceService);
});
it('should generate sequential numbers', async () => {
const num1 = await service.getNextSequence('COR');
const num2 = await service.getNextSequence('COR');
expect(extractSeq(num1)).toBe(1);
expect(extractSeq(num2)).toBe(2);
});
it('should skip cancelled numbers', async () => {
// Mark sequence 2 as cancelled
await markAsCancelled('COR', 2);
const num1 = await service.getNextSequence('COR');
const num2 = await service.getNextSequence('COR');
expect(extractSeq(num1)).toBe(1);
expect(extractSeq(num2)).toBe(3); // Skipped 2
});
it('should throw on sequence exhausted', async () => {
await setSequence('COR', 999999); // Max value
await expect(
service.getNextSequence('COR')
).rejects.toThrow(SequenceExhaustedError);
});
});
6.2 Integration Tests
// tests/integration/reservation.spec.ts
describe('Reservation Flow (Integration)', () => {
let app: INestApplication;
let redis: Redis;
beforeAll(async () => {
app = await createTestingApp();
redis = app.get(Redis);
});
it('should complete two-phase commit successfully', async () => {
// Phase 1: Reserve
const { body: reserve } = await request(app.getHttpServer())
.post('/document-numbering/reserve')
.send({ document_type: 'COR' })
.expect(201);
expect(reserve.data.token).toBeDefined();
expect(reserve.data.document_number).toMatch(/^COR-\d{4}-\d{5}$/);
// Verify reservation in Redis
const cached = await redis.get(`reservation:${reserve.data.token}`);
expect(cached).toBeDefined();
// Phase 2: Confirm
const { body: confirm } = await request(app.getHttpServer())
.post('/document-numbering/confirm')
.send({ token: reserve.data.token })
.expect(200);
expect(confirm.data.document_number).toBe(reserve.data.document_number);
// Verify reservation deleted
const deleted = await redis.get(`reservation:${reserve.data.token}`);
expect(deleted).toBeNull();
});
it('should handle cancel gracefully', async () => {
const { body: reserve } = await request(app.getHttpServer())
.post('/document-numbering/reserve')
.send({ document_type: 'COR' })
.expect(201);
await request(app.getHttpServer())
.post('/document-numbering/cancel')
.send({ token: reserve.data.token })
.expect(200);
// Verify reservation deleted
const deleted = await redis.get(`reservation:${reserve.data.token}`);
expect(deleted).toBeNull();
});
});
6.3 Load Tests
// tests/load/concurrency.spec.ts
describe('Concurrency Test', () => {
it('should handle 1000 concurrent requests without duplicates', async () => {
const promises = Array.from({ length: 1000 }, (_, i) =>
request(app.getHttpServer())
.post('/document-numbering/reserve')
.send({ document_type: 'COR' })
);
const results = await Promise.all(promises);
// Extract all document numbers
const numbers = results.map(r => r.body.data.document_number);
// Check for duplicates
const uniqueNumbers = new Set(numbers);
expect(uniqueNumbers.size).toBe(1000);
// Verify sequential
const sequences = numbers.map(n => extractSeq(n)).sort((a, b) => a - b);
expect(sequences[0]).toBe(1);
expect(sequences[999]).toBe(1000);
});
});
7. Deployment Checklist
7.1 Pre-Deployment
- Run all tests (unit, integration, E2E)
- Load test (1000 req/s for 5 min)
- Setup Redis cluster (3 nodes)
- Run database migrations
- Configure environment variables
- Setup monitoring (Prometheus + Grafana)
- Configure alerts (PagerDuty/Slack)
- Review security settings
- Backup database
- Document rollback procedure
7.2 Deployment Steps
- Deploy Redis cluster to staging
- Run migrations on staging database
- Deploy backend service to staging
- Run smoke tests on staging
- Load test staging environment
- Get approval from stakeholders
- Deploy to production (blue-green deployment)
- Monitor for 1 hour
- Gradual rollout (10% → 50% → 100%)
7.3 Post-Deployment
- Verify all metrics green
- Check error rates (<0.1%)
- Validate audit logs working
- Test critical workflows
- Monitor performance for 24 hours
- Collect user feedback
- Schedule retrospective
8. Monitoring & Observability
8.1 Prometheus Metrics
// metrics/numbering.metrics.ts
import { Injectable } from '@nestjs/common';
import { Counter, Gauge, Histogram, register } from 'prom-client';
@Injectable()
export class NumberingMetrics {
// Counter: Total numbers generated
private readonly numbersGenerated = new Counter({
name: 'numbering_sequences_total',
help: 'Total document numbers generated',
labelNames: ['document_type'],
});
// Gauge: Current sequence value
private readonly sequenceValue = new Gauge({
name: 'numbering_sequence_current',
help: 'Current sequence value',
labelNames: ['document_type', 'scope'],
});
// Gauge: Sequence utilization (%)
private readonly sequenceUtilization = new Gauge({
name: 'numbering_sequence_utilization',
help: 'Sequence utilization percentage',
labelNames: ['document_type'],
});
// Histogram: Lock wait time
private readonly lockWaitTime = new Histogram({
name: 'numbering_lock_wait_seconds',
help: 'Time spent waiting for lock acquisition',
labelNames: ['document_type'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
});
// Counter: Lock failures
private readonly lockFailures = new Counter({
name: 'numbering_lock_failures_total',
help: 'Total lock acquisition failures',
labelNames: ['document_type', 'reason'],
});
// Counter: Manual overrides
private readonly manualOverrides = new Counter({
name: 'numbering_manual_overrides_total',
help: 'Total manual overrides',
labelNames: ['document_type'],
});
incrementNumbersGenerated(documentType: string) {
this.numbersGenerated.inc({ document_type: documentType });
}
setSequenceValue(documentType: string, scope: string, value: number) {
this.sequenceValue.set({ document_type: documentType, scope }, value);
}
setSequenceUtilization(documentType: string, percent: number) {
this.sequenceUtilization.set({ document_type: documentType }, percent);
}
observeLockWaitTime(documentType: string, seconds: number) {
this.lockWaitTime.observe({ document_type: documentType }, seconds);
}
incrementLockFailures(documentType: string, reason: string) {
this.lockFailures.inc({ document_type: documentType, reason });
}
incrementManualOverrides(documentType: string) {
this.manualOverrides.inc({ document_type: documentType });
}
}
8.2 Grafana Dashboard
{
"dashboard": {
"title": "Document Numbering",
"panels": [
{
"title": "Numbers Generated per Minute",
"targets": [
{
"expr": "rate(numbering_sequences_total[1m])"
}
]
},
{
"title": "Sequence Utilization",
"targets": [
{
"expr": "numbering_sequence_utilization"
}
],
"thresholds": [90, 95]
},
{
"title": "Lock Wait Time (p95)",
"targets": [
{
"expr": "histogram_quantile(0.95, numbering_lock_wait_seconds)"
}
]
},
{
"title": "Lock Failures",
"targets": [
{
"expr": "rate(numbering_lock_failures_total[5m])"
}
]
}
]
}
}
8.3 Alert Rules
# alerts/numbering.yml
groups:
- name: numbering_alerts
interval: 30s
rules:
# Critical: Sequence >95% used
- alert: SequenceCritical
expr: numbering_sequence_utilization > 95
for: 5m
labels:
severity: critical
annotations:
summary: "Sequence {{ $labels.document_type }} >95% used"
description: "Current: {{ $value }}%. Extend max_value immediately."
# Warning: Sequence >90% used
- alert: SequenceWarning
expr: numbering_sequence_utilization > 90
for: 10m
labels:
severity: warning
annotations:
summary: "Sequence {{ $labels.document_type }} >90% used"
description: "Current: {{ $value }}%. Plan to extend max_value."
# Critical: High lock wait time
- alert: HighLockWaitTime
expr: histogram_quantile(0.95, numbering_lock_wait_seconds) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "Lock wait time >1s (p95)"
description: "p95: {{ $value }}s. Check Redis cluster health."
# Critical: Redis down
- alert: RedisUnavailable
expr: up{job="redis-numbering"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Redis cluster unavailable"
description: "Numbering system using DB-only fallback mode."
# Warning: High error rate
- alert: HighErrorRate
expr: rate(numbering_errors_total[5m]) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate in numbering system"
description: "{{ $value }} errors/sec. Check logs."
9. Troubleshooting Guide
9.1 Common Issues
Issue 1: Duplicate Numbers Generated
Symptoms: Same document number appears twice
Diagnosis:
-- Find duplicates
SELECT document_number, COUNT(*) as count
FROM (
SELECT document_number FROM correspondences
UNION ALL
SELECT document_number FROM rfas
UNION ALL
SELECT document_number FROM drawings
) AS all_docs
GROUP BY document_number
HAVING count > 1;
Root Causes:
- Redis cluster failure during generation
- Database deadlock
- Bug in locking logic
Resolution:
- Identify affected documents
- Manually reassign one with new number
- Update audit log
- Review lock acquisition logs
- Add additional monitoring
Issue 2: Sequence Exhausted
Symptoms: Error "Sequence exhausted for COR"
Diagnosis:
-- Check current vs max
SELECT
c.document_type,
s.current_value,
c.max_value,
(s.current_value * 100.0 / c.max_value) as utilization
FROM document_numbering_sequences s
JOIN document_numbering_configs c ON s.config_id = c.id
WHERE s.current_value >= c.max_value * 0.9;
Resolution:
-- Extend max_value
UPDATE document_numbering_configs
SET max_value = max_value * 10
WHERE document_type = 'COR';
-- Or reset yearly sequence (if applicable)
UPDATE document_numbering_sequences
SET current_value = 0,
scope_value = '2026' -- New year
WHERE config_id = (
SELECT id FROM document_numbering_configs
WHERE document_type = 'COR'
);
Issue 3: Lock Timeout
Symptoms: "Failed to acquire lock after 3 retries"
Diagnosis:
# Check Redis cluster health
redis-cli --cluster check localhost:7000
# Check lock contention
redis-cli KEYS "numbering:*"
redis-cli GET "numbering:COR:project-1"
Root Causes:
- High concurrent load
- Redis node down
- Network latency
- Deadlock in database
Resolution:
- Check Redis cluster health
- Increase lock timeout (5s → 10s)
- Add more Redis nodes
- Review database slow queries
- Implement exponential backoff
Issue 4: Reservation Expired
Symptoms: User gets "Reservation expired" error
Diagnosis:
# Check Redis TTL
redis-cli TTL "reservation:uuid-here"
# List all reservations
redis-cli KEYS "reservation:*"
Root Causes:
- User took >5 minutes to complete form
- Network issue during confirmation
- Browser closed/refreshed
Resolution:
- Reserve new number
- Consider increasing TTL (5 min → 10 min)
- Add progress auto-save
- Show countdown timer in UI
9.2 Debug Commands
# Check sequence status
npm run cli numbering:status COR
# Manually adjust sequence
npm run cli numbering:set COR 1000
# Validate sequence integrity
npm run cli numbering:validate
# Export audit logs
npm run cli numbering:audit-export \
--start "2025-01-01" \
--end "2025-01-31" \
--format csv \
--output audit.csv
# Simulate load
npm run cli numbering:load-test \
--type COR \
--requests 1000 \
--concurrency 100
# Check for duplicates
npm run cli numbering:check-duplicates
10. Performance Optimization
10.1 Database Indexes
-- Composite index for faster lookups
CREATE INDEX idx_sequence_lookup
ON document_numbering_sequences(config_id, scope_value);
-- Covering index for metrics
CREATE INDEX idx_audit_metrics
ON document_numbering_audit_logs(document_type, timestamp)
INCLUDE (operation, user_id);
-- Index for duplicate checking
CREATE INDEX idx_doc_number
ON correspondences(document_number);
CREATE INDEX idx_doc_number
ON rfas(document_number);
CREATE INDEX idx_doc_number
ON drawings(document_number);
10.2 Connection Pooling
// config/database.config.ts
export default {
type: 'mariadb',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
database: process.env.DB_NAME,
// Connection pool settings
extra: {
connectionLimit: 20, // Max connections
queueLimit: 0, // Unlimited queue
waitForConnections: true,
acquireTimeout: 30000, // 30s timeout
idleTimeout: 10000, // 10s idle timeout
maxIdle: 5, // Max idle connections
},
};
10.3 Redis Optimization
// config/redis.config.ts
export default {
cluster: [
{ host: 'redis-1', port: 6379 },
{ host: 'redis-2', port: 6379 },
{ host: 'redis-3', port: 6379 },
],
options: {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
maxLoadingRetryTime: 10000,
lazyConnect: false,
// Connection pool
maxRedirections: 16,
retryDelayOnFailover: 100,
retryDelayOnClusterDown: 300,
// Performance
enableOfflineQueue: true,
connectTimeout: 10000,
keepAlive: 30000,
},
};
10.4 Caching Strategy
// Cache configuration
@Cacheable({
ttl: 3600, // 1 hour
key: 'numbering:config:{documentType}',
compress: true,
})
async getConfig(documentType: string) {
return await this.configRepo.findOne({
where: { document_type: documentType },
});
}
// Cache invalidation
@CacheEvict({
key: 'numbering:config:{documentType}',
})
async updateConfig(documentType: string, data: any) {
return await this.configRepo.update(
{ document_type: documentType },
data,
);
}
11. Security Considerations
11.1 Access Control
// guards/manual-override.guard.ts
@Injectable()
export class ManualOverrideGuard implements CanActivate {
constructor(private caslAbility: CaslAbilityFactory) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check permission
const ability = this.caslAbility.createForUser(user);
return ability.can('manual_override', 'DocumentNumber');
}
}
// Usage
@Post('manual')
@UseGuards(JwtAuthGuard, ManualOverrideGuard)
async manualOverride(@Body() dto: ManualOverrideDto) {
// Only admins can access this
}
11.2 Rate Limiting
// Apply rate limit to prevent abuse
@Throttle(100, 60) // 100 requests per minute
@Post('reserve')
async reserve(@Body() dto: ReserveNumberDto) {
// ...
}
11.3 Audit Logging
// decorators/audit-numbering.decorator.ts
export function AuditNumbering(operation: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const result = await originalMethod.apply(this, args);
// Log to audit
await this.auditService.log({
operation,
timestamp: new Date(),
user_id: args[0]?.user?.id,
metadata: { args },
});
return result;
};
return descriptor;
};
}
// Usage
@AuditNumbering('MANUAL_OVERRIDE')
async manualOverride(dto: ManualOverrideDto, user: User) {
// Automatically logged
}
12. Migration Scripts
12.1 Import Legacy Documents
// scripts/import-legacy-numbers.ts
import { DataSource } from 'typeorm';
import * as csv from 'csv-parser';
import * as fs from 'fs';
async function importLegacyNumbers() {
const dataSource = await createDataSource();
const results = [];
// Read CSV
fs.createReadStream('legacy-documents.csv')
.pipe(csv())
.on('data', (row) => results.push(row))
.on('end', async () => {
console.log(`Found ${results.length} legacy documents`);
let success = 0;
let failed = 0;
for (const row of results) {
try {
await dataSource.transaction(async (manager) => {
// 1. Create document
await manager.insert('correspondences', {
document_number: row.document_number,
title: row.title,
created_at: row.created_at,
is_imported: true,
});
// 2. Log audit
await manager.insert('document_numbering_audit_logs', {
operation: 'MANUAL_OVERRIDE',
document_type: 'COR',
document_number: row.document_number,
metadata: { imported: true, source: 'legacy' },
});
success++;
});
} catch (error) {
console.error(`Failed to import ${row.document_number}:`, error);
failed++;
}
}
console.log(`Import complete: ${success} success, ${failed} failed`);
// 3. Update sequence counters
await updateSequenceCounters(dataSource);
});
}
async function updateSequenceCounters(dataSource: DataSource) {
const result = await dataSource.query(`
SELECT MAX(CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED)) as max_seq
FROM correspondences
WHERE document_number LIKE 'COR-2025-%'
`);
const maxSeq = result[0].max_seq;
await dataSource.query(`
UPDATE document_numbering_sequences
SET current_value = ?
WHERE config_id = (
SELECT id FROM document_numbering_configs
WHERE document_type = 'COR'
)
`, [maxSeq]);
console.log(`Updated COR sequence to ${maxSeq}`);
}
importLegacyNumbers().catch(console.error);
13. CLI Tools
13.1 Status Command
// cli/commands/numbering-status.command.ts
import { Command, CommandRunner } from 'nest-commander';
import { SequenceService } from '../../modules/document-numbering/services';
@Command({
name: 'numbering:status',
arguments: '[documentType]',
options: { isDefault: false },
})
export class NumberingStatusCommand extends CommandRunner {
constructor(private sequenceService: SequenceService) {
super();
}
async run(inputs: string[], options: any): Promise<void> {
const [documentType] = inputs;
if (documentType) {
await this.showTypeStatus(documentType);
} else {
await this.showAllStatus();
}
}
private async showTypeStatus(documentType: string) {
const config = await this.sequenceService.getConfig(documentType);
const sequence = await this.sequenceService.getSequence(documentType);
console.log(`\n📊 Status for ${documentType}:\n`);
console.log(`Format: ${config.format}`);
console.log(`Current Value: ${sequence.current_value}`);
console.log(`Max Value: ${config.max_value}`);
console.log(`Utilization: ${(sequence.current_value / config.max_value * 100).toFixed(2)}%`);
console.log(`Last Used: ${sequence.last_used_at}`);
console.log(`Manual Override: ${config.allow_manual_override ? 'Yes' : 'No'}`);
}
private async showAllStatus() {
const configs = await this.sequenceService.getAllConfigs();
console.log('\n📊 Document Numbering Status:\n');
console.table(
configs.map((c) => ({
Type: c.document_type,
Current: c.sequence?.current_value || 0,
Max: c.max_value,
'Utilization (%)': ((c.sequence?.current_value || 0) / c.max_value * 100).toFixed(2),
'Last Used': c.sequence?.last_used_at || 'Never',
})),
);
}
}
14. Best Practices Summary
14.1 DO's ✅
- ✅ Always use two-phase commit (reserve + confirm)
- ✅ Implement fallback to DB-only if Redis fails
- ✅ Log every operation to audit trail
- ✅ Monitor sequence utilization (alert at 90%)
- ✅ Test under concurrent load (1000+ req/s)
- ✅ Use pessimistic locking in database
- ✅ Set reasonable TTL for reservations (5 min)
- ✅ Validate manual override format
- ✅ Skip cancelled numbers (never reuse)
- ✅ Implement exponential backoff on retry
14.2 DON'Ts ❌
- ❌ Never skip validation for manual override
- ❌ Never reuse cancelled numbers
- ❌ Never trust client-generated numbers
- ❌ Never increase sequence without transaction
- ❌ Never ignore lock acquisition failures
- ❌ Never deploy without load testing
- ❌ Never extend max_value without planning
- ❌ Never modify sequence table directly
- ❌ Never skip audit logging
- ❌ Never assume Redis is always available
15. Appendix
15.1 Error Codes
export enum NumberingErrorCode {
CONFIG_NOT_FOUND = 'NB001',
SEQUENCE_EXHAUSTED = 'NB002',
LOCK_TIMEOUT = 'NB003',
RESERVATION_EXPIRED = 'NB004',
DUPLICATE_NUMBER = 'NB005',
INVALID_FORMAT = 'NB006',
MANUAL_OVERRIDE_NOT_ALLOWED = 'NB007',
REDIS_UNAVAILABLE = 'NB008',
}
15.2 Environment Variables
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_CLUSTER_NODES=redis-1:6379,redis-2:6379,redis-3:6379
# Numbering Configuration
NUMBERING_LOCK_TIMEOUT=5000 # 5 seconds
NUMBERING_RESERVATION_TTL=300 # 5 minutes
NUMBERING_RETRY_ATTEMPTS=3
NUMBERING_RETRY_DELAY=200 # milliseconds
# Monitoring
PROMETHEUS_PORT=9090
GRAFANA_PORT=3000
15.3 Useful Queries
-- Find next available number
SELECT MAX(CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED)) + 1
FROM correspondences
WHERE document_number LIKE 'COR-2025-%';
-- Check for gaps in sequence
SELECT t1.seq + 1 AS gap_start
FROM (
SELECT CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED) AS seq
FROM correspondences
WHERE document_number LIKE 'COR-2025-%'
) t1
LEFT JOIN (
SELECT CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED) AS seq
FROM correspondences
WHERE document_number LIKE 'COR-2025-%'
) t2 ON t1.seq + 1 = t2.seq
WHERE t2.seq IS NULL
ORDER BY gap_start;
-- Audit trail for specific number
SELECT *
FROM document_numbering_audit_logs
WHERE document_number = 'COR-2025-00042'
ORDER BY timestamp DESC;
Document Prepared By: Backend Team Last Review: 2025-01-16 Next Review: 2025-04-16