690503:0135 Update workflow #01
CI / CD Pipeline / build (push) Failing after 6m6s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-03 01:35:05 +07:00
parent d239b58387
commit 2c24991f88
85 changed files with 6335 additions and 100 deletions
@@ -32,7 +32,11 @@ import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dt
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
import { WorkflowHistoryItemDto } from './dto/workflow-history-item.dto';
import { CompiledWorkflow, WorkflowDslService } from './workflow-dsl.service';
import {
CompiledWorkflow,
RawWorkflowDSL,
WorkflowDslService,
} from './workflow-dsl.service';
import { WorkflowEventService } from './workflow-event.service'; // [NEW] Import Event Service
// Legacy Interface (Backward Compatibility)
@@ -79,7 +83,12 @@ export class WorkflowEngineService {
@InjectMetric('workflow_redlock_acquire_duration_ms')
private readonly redlockAcquireDuration: Histogram<string>,
@InjectMetric('workflow_redlock_acquire_failures_total')
private readonly redlockAcquireFailures: Counter<string>
private readonly redlockAcquireFailures: Counter<string>,
// FR-023: Per-transition metrics — labelled by workflow_code, action, outcome
@InjectMetric('workflow_transitions_total')
private readonly transitionsTotal: Counter<string>,
@InjectMetric('workflow_transition_duration_ms')
private readonly transitionDuration: Histogram<string>
) {
// ADR-021 Clarify Q2 (C1): Redlock Fail-closed
// Retry 3 ครั้ง × 500ms เพิ่ม jitter → ถ้ายังไม่ได้ throw HTTP 503
@@ -95,6 +104,30 @@ export class WorkflowEngineService {
// [PART 1] Definition Management (Phase 6A)
// =================================================================
/**
* FR-025: ตรวจสอบ DSL โดยไม่บันทึก — ใช้สำหรับ inline validation ใน Admin Editor
*/
validateDsl(
dsl: Record<string, unknown>
):
| { valid: true }
| { valid: false; errors: { path: string; message: string }[] } {
try {
this.dslService.compile(dsl as unknown as RawWorkflowDSL);
return { valid: true };
} catch (error: unknown) {
return {
valid: false,
errors: [
{
path: '',
message: error instanceof Error ? error.message : String(error),
},
],
};
}
}
/**
* สร้างหรืออัปเดต Workflow Definition ใหม่ (Auto Versioning)
*/
@@ -122,6 +155,12 @@ export class WorkflowEngineService {
});
const saved = await this.workflowDefRepo.save(entity);
// T044: Cache definition per version (TTL 1h, SC-005)
await this.cacheManager.set(
`wf:def:${saved.workflow_code}:${saved.version}`,
saved,
3_600_000
);
this.logger.log(
`Created Workflow Definition: ${saved.workflow_code} v${saved.version}`
);
@@ -155,10 +194,30 @@ export class WorkflowEngineService {
}
}
const prevIsActive = definition.is_active;
if (dto.is_active !== undefined) definition.is_active = dto.is_active;
if (dto.workflow_code) definition.workflow_code = dto.workflow_code;
return this.workflowDefRepo.save(definition);
const updated = await this.workflowDefRepo.save(definition);
// T045: Invalidate version cache เมื่อ DSL เปลี่ยน
if (dto.dsl) {
await this.cacheManager.del(
`wf:def:${updated.workflow_code}:${updated.version}`
);
}
// T045: Invalidate active pointer เมื่อ is_active เปลี่ยน
if (dto.is_active !== undefined && dto.is_active !== prevIsActive) {
await this.cacheManager.del(`wf:def:${updated.workflow_code}:active`);
}
// T045: Re-cache updated definition
await this.cacheManager.set(
`wf:def:${updated.workflow_code}:${updated.version}`,
updated,
3_600_000
);
return updated;
}
/**
@@ -181,10 +240,17 @@ export class WorkflowEngineService {
* ดึง Workflow Definition ตาม ID หรือ Code
*/
async getDefinitionById(id: string): Promise<WorkflowDefinition> {
// T046: Read-through cache (TTL 1h, SC-005)
const cacheKey = `wf:def:id:${id}`;
const cached = await this.cacheManager.get<WorkflowDefinition>(cacheKey);
if (cached) return cached;
const definition = await this.workflowDefRepo.findOne({ where: { id } });
if (!definition) {
throw new NotFoundException('Workflow Definition', id);
}
await this.cacheManager.set(cacheKey, definition, 3_600_000);
return definition;
}
@@ -317,7 +383,7 @@ export class WorkflowEngineService {
: [];
return {
id: instance.id,
id: instance.id, // publicId (UUID) ของ workflow instance
currentState: instance.currentState,
availableActions,
};
@@ -333,11 +399,49 @@ export class WorkflowEngineService {
comment?: string,
payload: Record<string, unknown> = {},
// ADR-021: publicIds ของไฟล์แนบประจำ Step นี้ (Two-Phase upload ก่อนแล้ว)
attachmentPublicIds?: string[]
attachmentPublicIds?: string[],
// ADR-019: UUID ของ User สำหรับ history record (ไม่ expose INT PK)
userUuid?: string,
// ADR-001 v1.1 FR-002: Optimistic lock — Client ส่งมาเพื่อป้องกัน Double-approval
clientVersionNo?: number
) {
// FR-022/023: เริ่มจับเวลาทั้ง method เพื่อบันทึก latency metric
const startMs = Date.now();
let outcome:
| 'success'
| 'conflict'
| 'forbidden'
| 'validation_error'
| 'system_error' = 'system_error';
let workflowCode = 'unknown';
let fromState: string | undefined;
let toState: string | undefined;
const hasAttachments =
attachmentPublicIds !== undefined && attachmentPublicIds.length > 0;
// ==============================================================
// ADR-001 v1.1 FR-002: Fast-fail Optimistic Lock Check (ก่อน Redlock)
// ลดภาระ Redlock สำหรับ Client ที่ส่ง version_no ล้าสมัยมา
// ==============================================================
if (clientVersionNo !== undefined) {
const current = await this.instanceRepo.findOne({
where: { id: instanceId },
select: ['id', 'versionNo'],
});
if (!current) {
throw new NotFoundException('Workflow Instance', instanceId);
}
if (current.versionNo !== clientVersionNo) {
outcome = 'conflict';
throw new ConflictException(
'WORKFLOW_VERSION_CONFLICT',
`Fast-fail: expected version_no=${clientVersionNo}, actual=${current.versionNo}`,
'เอกสารถูกอนุมัติโดยผู้อื่นแล้ว กรุณารีเฟรชและลองใหม่',
['รีเฟรชหน้าแล้วดูสถานะล่าสุดก่อนดำเนินการ']
);
}
}
// ==============================================================
// ADR-021 Clarify Q1 (C3): ตรวจสถานะก่อน acquire Redlock
// อนุญาตให้แนบไฟล์เฉพาะในสถานะ PENDING_REVIEW / PENDING_APPROVAL
@@ -453,8 +557,10 @@ export class WorkflowEngineService {
context
);
const fromState = instance.currentState;
const toState = evaluation.nextState;
fromState = instance.currentState;
toState = evaluation.nextState;
// FR-023: บันทึก workflowCode สำหรับ metric labels
workflowCode = instance.definition?.workflow_code ?? 'unknown';
// 3. อัปเดต Instance
instance.currentState = toState;
@@ -474,6 +580,8 @@ export class WorkflowEngineService {
toState,
action,
actionByUserId: userId,
// ADR-019 FR-003: UUID ของ User สำหรับ API Response (INT PK ไม่ expose)
actionByUserUuid: userUuid,
comment,
metadata: {
events: evaluation.events,
@@ -516,6 +624,27 @@ export class WorkflowEngineService {
}
}
// ADR-001 v1.1 FR-002: CAS version increment หลัง commit ใน DB transaction
// UPDATE จะล้มเหลว (affected=0) ถ้า version_no ถูกเปลี่ยนระหว่างนี้ (TOCTOU edge case)
const casResult = await queryRunner.manager
.createQueryBuilder()
.update(WorkflowInstance)
.set({ versionNo: () => 'version_no + 1' })
.where('id = :id AND version_no = :expected', {
id: instanceId,
expected: instance.versionNo,
})
.execute();
if ((casResult.affected ?? 0) === 0) {
throw new ConflictException(
'WORKFLOW_VERSION_CONFLICT',
'version_no changed between Redlock acquisition and CAS update (TOCTOU edge case)',
'เกิด Conflict กรุณารีเฟรชและลองใหม่',
['รีเฟรชหน้า', 'ลองดำเนินการอีกครั้ง']
);
}
await queryRunner.commitTransaction();
// ADR-021 T043: Invalidate Workflow History cache หลัง transition สำเร็จ
@@ -536,23 +665,85 @@ export class WorkflowEngineService {
void this.eventService.dispatchEvents(
instance.id,
evaluation.events,
context
context,
workflowCode // FR-005: DLQ notification \u0e43\u0e0a\u0e49 workflowCode \u0e23\u0e30\u0e1a\u0e38\u0e1a\u0e23\u0e34\u0e1a\u0e17\u0e18\u0e34\u0e4c Ops
);
}
outcome = 'success';
// FR-014 T014: คืน versionNo ที่ increment แล้ว ให้ Client เก็บไว้สำหรับ request ถัดไป
const newVersionNo = instance.versionNo + 1;
return {
success: true,
previousState: fromState,
nextState: toState,
events: evaluation.events,
isCompleted: instance.status === WorkflowStatus.COMPLETED,
versionNo: newVersionNo,
};
} catch (err) {
await queryRunner.rollbackTransaction();
// FR-019: Rollback file attachments กลับเป็น temporary เมื่อ DB transaction ล้มเหลว
// ไฟล์บน disk ยังคงอยู่ที่ permanent storage; cleanup job จะจัดการหลัง 24h TTL
if (
hasAttachments &&
attachmentPublicIds &&
attachmentPublicIds.length > 0
) {
await this.attachmentRepo
.update(
{ publicId: In(attachmentPublicIds), uploadedByUserId: userId },
{
isTemporary: true,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
)
.catch((rollbackErr: unknown) =>
this.logger.error(
`FR-019 Attachment rollback failed for ${instanceId}: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`
)
);
this.logger.warn(
`FR-019: Reverted ${attachmentPublicIds.length} attachment(s) to temp for instance ${instanceId} after DB failure`
);
}
// จำแนก outcome สำหรับ metric label
if (err instanceof ConflictException) outcome = 'conflict';
else if ((err as { status?: number }).status === 403)
outcome = 'forbidden';
else if (err instanceof WorkflowException) outcome = 'validation_error';
this.logger.error(
`Transition Failed for ${instanceId}: ${(err as Error).message}`
);
throw err;
} finally {
const durationMs = Date.now() - startMs;
// FR-023: บันทึก transition duration histogram
this.transitionDuration
.labels({ workflow_code: workflowCode })
.observe(durationMs);
// FR-023: บันทึก transition counter ตาม outcome
this.transitionsTotal
.labels({ workflow_code: workflowCode, action, outcome })
.inc();
// FR-022: Structured log entry ทุก transition (success/failure/conflict)
this.logger.log(
JSON.stringify({
instanceId,
action,
fromState,
toState,
userUuid,
durationMs,
outcome,
workflowCode,
})
);
await queryRunner.release();
// ADR-021 C1: ปล่อย Redlock เสมอ (non-blocking หาก release ผิดพลาด)
lock.release().catch((e: unknown) => {