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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add dead_letter_id FK to work_queue for linking requeued entries back to their dead letter
ALTER TABLE trax.work_queue ADD COLUMN IF NOT EXISTS dead_letter_id bigint
REFERENCES trax.dead_letter(id) ON DELETE RESTRICT;
6 changes: 6 additions & 0 deletions src/Trax.Effect.Data/Models/WorkQueue/PersistentWorkQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ internal static void OnModelCreating(ModelBuilder modelBuilder)
.WithMany()
.HasForeignKey(x => x.MetadataId)
.OnDelete(DeleteBehavior.Restrict);

entity
.HasOne(x => x.DeadLetter)
.WithMany()
.HasForeignKey(x => x.DeadLetterId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
29 changes: 23 additions & 6 deletions src/Trax.Effect/Models/DeadLetter/DeadLetter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,12 @@ public class DeadLetter : IModel
/// </summary>
/// <param name="createDeadLetter"></param>
/// <returns>A new DeadLetter instance</returns>
/// <remarks>
/// Note: Only ManifestId is set, not the Manifest navigation property.
/// This avoids EF Core tracking issues when the Manifest was loaded with .Include().
/// </remarks>
public static DeadLetter Create(CreateDeadLetter createDeadLetter)
{
return new DeadLetter
{
ManifestId = createDeadLetter.Manifest.Id,
Manifest = createDeadLetter.Manifest,
// Don't set Manifest navigation property to avoid EF Core tracking issues
// when the manifest was loaded with includes
DeadLetteredAt = DateTime.UtcNow,
Reason = createDeadLetter.Reason,
RetryCountAtDeadLetter = createDeadLetter.RetryCount,
Expand Down Expand Up @@ -139,6 +133,29 @@ public void MarkRetried(long retryMetadataId)
RetryMetadataId = retryMetadataId;
}

/// <summary>
/// Marks this dead letter as requeued. The retry metadata ID is linked later
/// when the requeued WorkQueue entry is dispatched by the JobDispatcher.
/// </summary>
/// <param name="note">A note explaining the requeue action</param>
public void Requeue(string note)
{
Status = DeadLetterStatus.Retried;
ResolvedAt = DateTime.UtcNow;
ResolutionNote = note;
}

/// <summary>
/// Links the retry metadata after the requeued job has been dispatched.
/// Called by the JobDispatcher when it creates a Metadata record for a
/// WorkQueue entry that originated from a dead letter requeue.
/// </summary>
/// <param name="retryMetadataId">The ID of the Metadata record created for the retry execution</param>
public void LinkRetryMetadata(long retryMetadataId)
{
RetryMetadataId = retryMetadataId;
}

public override string ToString() =>
JsonSerializer.Serialize(
this,
Expand Down
5 changes: 5 additions & 0 deletions src/Trax.Effect/Models/WorkQueue/DTOs/CreateWorkQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@ public class CreateWorkQueue
/// Null means dispatch immediately.
/// </summary>
public DateTime? ScheduledAt { get; set; }

/// <summary>
/// Optional dead letter ID when this entry is created by requeuing a dead letter.
/// </summary>
public long? DeadLetterId { get; set; }
}
13 changes: 13 additions & 0 deletions src/Trax.Effect/Models/WorkQueue/WorkQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ public class WorkQueue : IModel
/// </summary>
public Metadata.Metadata? Metadata { get; set; }

/// <summary>
/// Optional dead letter ID — set when this entry was created by requeuing a dead letter.
/// Used by the JobDispatcher to link the retry metadata back to the dead letter.
/// </summary>
[Column("dead_letter_id")]
public long? DeadLetterId { get; set; }

/// <summary>
/// The dead letter record that triggered this requeue, if applicable.
/// </summary>
public DeadLetter.DeadLetter? DeadLetter { get; set; }

#endregion

#region Functions
Expand All @@ -138,6 +150,7 @@ public static WorkQueue Create(CreateWorkQueue dto)
ManifestId = dto.ManifestId,
Priority = Math.Clamp(dto.Priority, MinPriority, MaxPriority),
ScheduledAt = dto.ScheduledAt,
DeadLetterId = dto.DeadLetterId,
Status = WorkQueueStatus.Queued,
CreatedAt = DateTime.UtcNow,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,137 @@ public void StatusTransition_FromAwaitingIntervention_ToRetried()
// Assert final state
deadLetter.Status.Should().Be(DeadLetterStatus.Retried);
}

#region Requeue and LinkRetryMetadata

[Test]
public void Requeue_SetsStatusRetriedAndResolvedAt()
{
// Arrange
var manifest = Manifest.Create(
new CreateManifest
{
Name = typeof(DeadLetterTests),
IsEnabled = true,
ScheduleType = ScheduleType.None,
MaxRetries = 3,
}
);
var deadLetter = DeadLetter.Create(
new CreateDeadLetter
{
Manifest = manifest,
Reason = "Max retries exceeded",
RetryCount = 3,
}
);
var note = "Re-queued via dashboard (WorkQueue 42)";

// Act
deadLetter.Requeue(note);

// Assert
deadLetter.Status.Should().Be(DeadLetterStatus.Retried);
deadLetter.ResolutionNote.Should().Be(note);
deadLetter.ResolvedAt.Should().NotBeNull();
deadLetter.ResolvedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
deadLetter.RetryMetadataId.Should().BeNull();
}

[Test]
public void Requeue_DoesNotRequireMetadataId()
{
// Arrange
var manifest = Manifest.Create(
new CreateManifest
{
Name = typeof(DeadLetterTests),
IsEnabled = true,
ScheduleType = ScheduleType.None,
MaxRetries = 3,
}
);
var deadLetter = DeadLetter.Create(
new CreateDeadLetter
{
Manifest = manifest,
Reason = "Max retries exceeded",
RetryCount = 3,
}
);

// Act
deadLetter.Requeue("Requeued for retry");

// Assert — RetryMetadataId is NOT set; it gets linked later by the dispatcher
deadLetter.RetryMetadataId.Should().BeNull();
deadLetter.Status.Should().Be(DeadLetterStatus.Retried);
}

[Test]
public void LinkRetryMetadata_SetsRetryMetadataId()
{
// Arrange
var manifest = Manifest.Create(
new CreateManifest
{
Name = typeof(DeadLetterTests),
IsEnabled = true,
ScheduleType = ScheduleType.None,
MaxRetries = 3,
}
);
var deadLetter = DeadLetter.Create(
new CreateDeadLetter
{
Manifest = manifest,
Reason = "Max retries exceeded",
RetryCount = 3,
}
);

// Requeue first (the normal flow)
deadLetter.Requeue("Re-queued via dashboard");

// Act — dispatcher links the metadata after creating it
deadLetter.LinkRetryMetadata(99);

// Assert
deadLetter.RetryMetadataId.Should().Be(99);
deadLetter.Status.Should().Be(DeadLetterStatus.Retried);
}

[Test]
public void StatusTransition_FromAwaitingIntervention_ToRetriedViaRequeue()
{
// Arrange
var manifest = Manifest.Create(
new CreateManifest
{
Name = typeof(DeadLetterTests),
IsEnabled = true,
ScheduleType = ScheduleType.None,
MaxRetries = 3,
}
);
var deadLetter = DeadLetter.Create(
new CreateDeadLetter
{
Manifest = manifest,
Reason = "Test reason",
RetryCount = 1,
}
);

// Assert initial state
deadLetter.Status.Should().Be(DeadLetterStatus.AwaitingIntervention);

// Act
deadLetter.Requeue("Requeued");

// Assert final state
deadLetter.Status.Should().Be(DeadLetterStatus.Retried);
}

#endregion
}
Loading