291 lines
7.6 KiB
Markdown
291 lines
7.6 KiB
Markdown
# TaskTracker: Simplified 2-Key Design
|
|
|
|
## Overview
|
|
|
|
Universal task execution tracking for all queue types (partner_tasks, jobs, notifications) with a simplified **2-key design** instead of the traditional 3-key approach.
|
|
|
|
## Architecture Decision: 2 Keys vs 3 Keys
|
|
|
|
### Traditional Approach (3 Keys)
|
|
```javascript
|
|
{
|
|
taskId: "partner_tasks:SATLOC:695d:02220710", // Business identity
|
|
idempotencyKey: "a1b2c3d4-...", // Execution identity
|
|
correlationId: "partner_tasks:SATLOC:695d:02220710" // Trace chain
|
|
}
|
|
```
|
|
|
|
**Problems:**
|
|
- Redundant: correlationId often same as taskId
|
|
- Complex: 3 fields to manage, more indexes needed
|
|
- Confusion: When to use which ID?
|
|
|
|
### Simplified Approach (2 Keys) ✅
|
|
|
|
```javascript
|
|
{
|
|
taskId: "partner_tasks:SATLOC:695d:02220710", // Business identity (stable)
|
|
executionId: "a1b2c3d4-e5f6-..." // Execution identity (unique)
|
|
}
|
|
// Note: No separate correlationId - taskId serves this purpose!
|
|
```
|
|
|
|
**Benefits:**
|
|
- ✅ **Simpler**: 2 keys instead of 3
|
|
- ✅ **Same functionality**: Deduplication + Idempotency + Tracing
|
|
- ✅ **Better performance**: Fewer indexes, faster queries
|
|
- ✅ **Easier to understand**: Clear separation of concerns
|
|
|
|
## How It Works
|
|
|
|
### 1. Deduplication (Prevent Duplicate Enqueues)
|
|
|
|
**Use taskId only:**
|
|
|
|
```javascript
|
|
const taskId = generateTaskId('dev_partner_tasks', message);
|
|
// => "partner_tasks:SATLOC:695d:02220710"
|
|
|
|
// Check if already queued/processing
|
|
const recentTask = await TaskTracker.findOne({
|
|
taskId,
|
|
status: { $in: ['queued', 'processing'] },
|
|
enqueuedAt: { $gt: new Date(Date.now() - 5 * 60000) }
|
|
});
|
|
|
|
if (recentTask) {
|
|
return; // Skip duplicate
|
|
}
|
|
```
|
|
|
|
### 2. Idempotency (Prevent Duplicate Processing)
|
|
|
|
**Use taskId + executionId:**
|
|
|
|
```javascript
|
|
const executionId = generateExecutionId();
|
|
// => UUID v4: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
|
|
// Atomic claim
|
|
const tracker = await TaskTracker.findOneAndUpdate(
|
|
{
|
|
taskId, // Same business task
|
|
executionId, // Specific execution attempt
|
|
status: { $in: ['queued', 'failed'] }
|
|
},
|
|
{ $set: { status: 'processing' } },
|
|
{ new: true }
|
|
);
|
|
|
|
if (!tracker) {
|
|
return; // Already processed by another worker
|
|
}
|
|
```
|
|
|
|
### 3. Tracing (Track Retry Chains)
|
|
|
|
**Use taskId for tracing (no separate correlationId needed!):**
|
|
|
|
```javascript
|
|
// Find complete retry history - single query!
|
|
const retryChain = await TaskTracker.find({ taskId })
|
|
.sort({ createdAt: 1 })
|
|
.lean();
|
|
|
|
// Returns all attempts:
|
|
// [
|
|
// { executionId: "uuid-1", status: "failed", error: "timeout" },
|
|
// { executionId: "uuid-2", status: "failed", error: "timeout" },
|
|
// { executionId: "uuid-3", status: "completed" }
|
|
// ]
|
|
```
|
|
|
|
**Why no correlationId?** The `taskId` itself correlates all retries! When you retry from DLQ:
|
|
- Keep same `taskId` (business identity preserved)
|
|
- Generate new `executionId` (new execution attempt)
|
|
- Query `{ taskId }` returns entire chain automatically
|
|
|
|
## Files
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `model/task_tracker.js` | TaskTracker model with 2-key design |
|
|
| `services/task_id_generator.js` | Generate taskId and executionId |
|
|
| `tests/test_task_tracker_2key.js` | Test script demonstrating 2-key design |
|
|
|
|
## Usage Example
|
|
|
|
### At Enqueue Time
|
|
|
|
```javascript
|
|
const { generateTaskId, generateExecutionId } = require('./services/task_id_generator');
|
|
const TaskTracker = require('./model/task_tracker');
|
|
|
|
// Generate IDs
|
|
const taskId = generateTaskId('dev_partner_tasks', message);
|
|
const executionId = generateExecutionId();
|
|
|
|
// Deduplication check
|
|
const existing = await TaskTracker.findOne({
|
|
taskId,
|
|
status: { $in: ['queued', 'processing'] },
|
|
enqueuedAt: { $gt: new Date(Date.now() - 5 * 60000) }
|
|
});
|
|
|
|
if (existing) {
|
|
console.log('Task already queued, skipping');
|
|
return;
|
|
}
|
|
|
|
// Create tracker
|
|
await TaskTracker.create({
|
|
taskId,
|
|
executionId,
|
|
queueName: 'dev_partner_tasks',
|
|
status: 'queued',
|
|
metadata: {
|
|
partnerCode: message.partnerCode,
|
|
aircraftId: message.aircraftId,
|
|
logId: message.logId,
|
|
customerId: message.customerId
|
|
}
|
|
});
|
|
|
|
// Enqueue message with IDs
|
|
await channel.sendToQueue(queueName, Buffer.from(JSON.stringify({
|
|
...message,
|
|
taskId,
|
|
executionId
|
|
})), {
|
|
persistent: true,
|
|
headers: { taskId, executionId }
|
|
});
|
|
```
|
|
|
|
### In Worker
|
|
|
|
```javascript
|
|
// Extract from message
|
|
const { taskId, executionId } = JSON.parse(msg.content.toString());
|
|
|
|
// Atomic claim (idempotency)
|
|
const tracker = await TaskTracker.findOneAndUpdate(
|
|
{ taskId, executionId, status: { $in: ['queued', 'failed'] } },
|
|
{ $set: { status: 'processing', processingStartedAt: new Date() } },
|
|
{ new: true }
|
|
);
|
|
|
|
if (!tracker) {
|
|
console.log('Already processed, skipping');
|
|
channel.ack(msg);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Process task
|
|
await processTask(message);
|
|
|
|
// Mark completed
|
|
await TaskTracker.updateOne(
|
|
{ executionId },
|
|
{ $set: { status: 'completed', completedAt: new Date() } }
|
|
);
|
|
|
|
channel.ack(msg);
|
|
|
|
} catch (error) {
|
|
if (tracker.canRetry()) {
|
|
await TaskTracker.updateOne(
|
|
{ executionId },
|
|
{
|
|
$set: { status: 'failed', errorMessage: error.message },
|
|
$inc: { retryCount: 1 }
|
|
}
|
|
);
|
|
channel.nack(msg, false, true); // Requeue
|
|
} else {
|
|
await TaskTracker.updateOne(
|
|
{ executionId },
|
|
{ $set: { status: 'dlq', errorMessage: error.message } }
|
|
);
|
|
channel.nack(msg, false, false); // Send to DLQ
|
|
}
|
|
}
|
|
```
|
|
|
|
### Tracing Full History
|
|
|
|
```javascript
|
|
// Find all attempts for a task (automatic correlation via taskId!)
|
|
const history = await TaskTracker.find({ taskId })
|
|
.sort({ createdAt: 1 })
|
|
.lean();
|
|
|
|
history.forEach((execution, index) => {
|
|
console.log(`Attempt ${index + 1}:`);
|
|
console.log(` executionId: ${execution.executionId}`);
|
|
console.log(` status: ${execution.status}`);
|
|
console.log(` error: ${execution.errorMessage || 'N/A'}`);
|
|
console.log(` created: ${execution.createdAt}`);
|
|
});
|
|
```
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
# Run test script
|
|
node tests/test_task_tracker_2key.js
|
|
|
|
# Expected output:
|
|
# ✓ taskId generation
|
|
# ✓ executionId generation (unique per attempt)
|
|
# ✓ Deduplication works
|
|
# ✓ Idempotency works (atomic claim)
|
|
# ✓ Retry chain tracing (no correlationId needed!)
|
|
```
|
|
|
|
## Migration from PartnerLogTracker
|
|
|
|
### Phase 1: Parallel Tracking
|
|
- Deploy TaskTracker alongside existing PartnerLogTracker
|
|
- Workers update both systems
|
|
- Validate consistency
|
|
|
|
### Phase 2: Switch Reads
|
|
- APIs query TaskTracker instead of PartnerLogTracker
|
|
- Workers still update both
|
|
- Monitor performance
|
|
|
|
### Phase 3: Deprecate Old System
|
|
- Workers stop updating PartnerLogTracker
|
|
- Archive old data
|
|
- Remove old model and indexes
|
|
|
|
## Benefits Summary
|
|
|
|
| Feature | Old (3 keys) | New (2 keys) |
|
|
|---------|--------------|--------------|
|
|
| **Keys** | taskId + idempotencyKey + correlationId | taskId + executionId |
|
|
| **Indexes** | 9+ | 6 |
|
|
| **Deduplication** | Manual logic | Built-in via taskId |
|
|
| **Idempotency** | Complex checks | Atomic via taskId + executionId |
|
|
| **Tracing** | Separate correlationId | Automatic via taskId |
|
|
| **Query Performance** | Slower (more fields) | Faster (fewer fields) |
|
|
| **Code Complexity** | Higher | Lower |
|
|
| **Universal** | No (partner_tasks only) | Yes (all queue types) |
|
|
|
|
## Key Insight
|
|
|
|
**The taskId IS the correlationId!** There's no need for a separate field because:
|
|
- All retries share the same `taskId` (business identity)
|
|
- Each retry has unique `executionId` (execution identity)
|
|
- Query `{ taskId }` naturally returns the entire retry chain
|
|
|
|
This reduces complexity while maintaining full functionality.
|
|
|
|
---
|
|
|
|
**Status**: Implementation complete
|
|
**Next Steps**: Test with real partner_tasks, then roll out to other queues
|
|
**Documentation**: See [PARTNER_TASK_DATA_FLOW_ANALYSIS.md](../docs/PARTNER_TASK_DATA_FLOW_ANALYSIS.md)
|