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
10 changes: 8 additions & 2 deletions backend/src/Taskdeck.Api/Controllers/LlmQueueController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ public async Task<IActionResult> GetUserQueue()
[HttpGet("status/{status}")]
public async Task<IActionResult> GetByStatus(string status)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

if (!Enum.TryParse<RequestStatus>(status, true, out var parsedStatus) || !Enum.IsDefined(parsedStatus))
return BadRequest(new ApiErrorResponse(
ErrorCodes.ValidationError,
$"Invalid status value: {status}"));

var result = await _llmQueueService.GetQueueByStatusAsync(parsedStatus);
var result = await _llmQueueService.GetQueueByStatusAsync(userId, parsedStatus);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

Expand All @@ -79,7 +82,10 @@ public async Task<IActionResult> ProcessNext()
[HttpGet("stats")]
public async Task<IActionResult> GetQueueStats()
{
var result = await _llmQueueService.GetQueueStatsAsync();
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

var result = await _llmQueueService.GetQueueStatsAsync(userId);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface ILlmQueueRepository : IRepository<LlmRequest>
Task<IEnumerable<LlmRequest>> GetPendingAsync(int limit = 100, CancellationToken cancellationToken = default);
Task<IEnumerable<LlmRequest>> GetByUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task<IEnumerable<LlmRequest>> GetByStatusAsync(RequestStatus status, CancellationToken cancellationToken = default);
Task<IEnumerable<LlmRequest>> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default);
Task<Dictionary<RequestStatus, int>> GetStatusCountsByUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task<LlmRequest?> GetNextPendingAsync(CancellationToken cancellationToken = default);
Task<bool> TryClaimProcessingCaptureAsync(
Guid requestId,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/Taskdeck.Application/Services/ILlmQueueService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public interface ILlmQueueService
{
Task<Result<LlmRequestDto>> AddToQueueAsync(Guid userId, CreateLlmRequestDto dto);
Task<Result<IEnumerable<LlmRequestDto>>> GetUserQueueAsync(Guid userId);
Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(RequestStatus status);
Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(Guid userId, RequestStatus status);
Task<Result> CancelRequestAsync(Guid requestId, Guid userId);
Task<Result<LlmRequestDto>> ProcessNextRequestAsync();
Task<Result<QueueStatsDto>> GetQueueStatsAsync();
Task<Result<QueueStatsDto>> GetQueueStatsAsync(Guid userId);
}
19 changes: 8 additions & 11 deletions backend/src/Taskdeck.Application/Services/LlmQueueService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ public async Task<Result<IEnumerable<LlmRequestDto>>> GetUserQueueAsync(Guid use
return Result.Success(requests.Select(MapToDto));
}

public async Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(RequestStatus status)
public async Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(Guid userId, RequestStatus status)
{
var requests = await _unitOfWork.LlmQueue.GetByStatusAsync(status);
var requests = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, status);
return Result.Success(requests.Select(MapToDto));
}

Expand Down Expand Up @@ -134,18 +134,15 @@ public async Task<Result<LlmRequestDto>> ProcessNextRequestAsync()
}
}

public async Task<Result<QueueStatsDto>> GetQueueStatsAsync()
public async Task<Result<QueueStatsDto>> GetQueueStatsAsync(Guid userId)
{
var pending = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Pending);
var processing = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Processing);
var completed = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Completed);
var failed = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Failed);
var statusCounts = await _unitOfWork.LlmQueue.GetStatusCountsByUserAsync(userId);

var stats = new QueueStatsDto(
pending.Count(),
processing.Count(),
completed.Count(),
failed.Count());
statusCounts.GetValueOrDefault(RequestStatus.Pending, 0),
statusCounts.GetValueOrDefault(RequestStatus.Processing, 0),
statusCounts.GetValueOrDefault(RequestStatus.Completed, 0),
statusCounts.GetValueOrDefault(RequestStatus.Failed, 0));

return Result.Success(stats);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,36 @@ public async Task<IEnumerable<LlmRequest>> GetByStatusAsync(RequestStatus status
.ToListAsync(cancellationToken);
}

public async Task<IEnumerable<LlmRequest>> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default)
{
if (_context.Database.IsSqlite())
{
return await _context.LlmRequests
.FromSqlInterpolated($"SELECT * FROM LlmRequests WHERE UserId = {userId} AND Status = {(int)status} ORDER BY CreatedAt DESC")
.AsNoTracking()
.Include(lr => lr.User)
.Include(lr => lr.Board)
.ToListAsync(cancellationToken);
}

return await _context.LlmRequests
.AsNoTracking()
.Include(lr => lr.User)
.Include(lr => lr.Board)
.Where(lr => lr.UserId == userId && lr.Status == status)
.OrderByDescending(lr => lr.CreatedAt)
.ToListAsync(cancellationToken);
}
Comment on lines +143 to +162
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since this method is used for read-only operations where the entities are mapped to DTOs, it would be more efficient to disable change tracking by adding .AsNoTracking() to the query. This avoids the overhead of setting up change tracking for entities that won't be updated.

    public async Task<IEnumerable<LlmRequest>> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default)
    {
        if (_context.Database.IsSqlite())
        {
            return await _context.LlmRequests
                .FromSqlInterpolated($"SELECT * FROM LlmRequests WHERE UserId = {userId} AND Status = {(int)status} ORDER BY CreatedAt DESC")
                .AsNoTracking()
                .Include(lr => lr.Board)
                .ToListAsync(cancellationToken);
        }

        return await _context.LlmRequests
            .AsNoTracking()
            .Include(lr => lr.Board)
            .Where(lr => lr.UserId == userId && lr.Status == status)
            .OrderByDescending(lr => lr.CreatedAt)
            .ToListAsync(cancellationToken);
    }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: added .AsNoTracking() to both SQLite and LINQ branches of GetByUserAndStatusAsync. Also added .Include(lr => lr.User) for consistency with GetByStatusAsync.


public async Task<Dictionary<RequestStatus, int>> GetStatusCountsByUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
return await _context.LlmRequests
.Where(r => r.UserId == userId)
.GroupBy(r => r.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.Status, g => g.Count, cancellationToken);
}

public async Task<LlmRequest?> GetNextPendingAsync(CancellationToken cancellationToken = default)
{
if (_context.Database.IsSqlite())
Expand Down
61 changes: 61 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/LlmQueueApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,67 @@ public async Task CancelRequest_ShouldReturnForbidden_WhenRequestBelongsToDiffer
await ApiTestHarness.AssertForbiddenAsync(response);
}

[Fact]
public async Task GetByStatus_ShouldOnlyReturnCurrentUserRequests()
{
using var userAClient = _factory.CreateClient();
using var userBClient = _factory.CreateClient();

var userA = await ApiTestHarness.AuthenticateAsync(userAClient, "llm-status-isolation-userA");
var boardA = await ApiTestHarness.CreateBoardAsync(userAClient, "llm-status-isolation-board-a");

// User A creates a queue item
var createResponse = await userAClient.PostAsJsonAsync(
"/api/llm-queue",
new CreateLlmRequestDto("summarize", "user-a payload", boardA.Id));
createResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var createdItem = await createResponse.Content.ReadFromJsonAsync<LlmRequestDto>();
createdItem.Should().NotBeNull();

// User B queries the same status — must not see user A's item
await ApiTestHarness.AuthenticateAsync(userBClient, "llm-status-isolation-userB");
var response = await userBClient.GetAsync("/api/llm-queue/status/Pending");

response.StatusCode.Should().Be(HttpStatusCode.OK);

var items = await response.Content.ReadFromJsonAsync<List<LlmRequestDto>>();
items.Should().NotBeNull();
items.Should().NotContain(item => item.UserId == userA.UserId,
"user B must not see user A's queue items");
}

[Fact]
public async Task GetQueueStats_ShouldOnlyCountCurrentUserRequests()
{
using var userAClient = _factory.CreateClient();
using var userBClient = _factory.CreateClient();

await ApiTestHarness.AuthenticateAsync(userAClient, "llm-stats-isolation-userA");
await ApiTestHarness.AuthenticateAsync(userBClient, "llm-stats-isolation-userB");

// Verify user B starts with zero pending
var baselineResponse = await userBClient.GetAsync("/api/llm-queue/stats");
baselineResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var baselineStats = await baselineResponse.Content.ReadFromJsonAsync<QueueStatsDto>();
baselineStats.Should().NotBeNull();
var baselinePending = baselineStats!.PendingCount;

// User A creates a queue item (no boardId — queue accepts null board)
var boardA = await ApiTestHarness.CreateBoardAsync(userAClient, "llm-stats-isolation-board");
var createResponse = await userAClient.PostAsJsonAsync(
"/api/llm-queue",
new CreateLlmRequestDto("summarize", "user-a stats payload", boardA.Id));
createResponse.StatusCode.Should().Be(HttpStatusCode.OK);

// User B's stats must not change
var afterResponse = await userBClient.GetAsync("/api/llm-queue/stats");
afterResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var afterStats = await afterResponse.Content.ReadFromJsonAsync<QueueStatsDto>();
afterStats.Should().NotBeNull();
afterStats!.PendingCount.Should().Be(baselinePending,
"user A's pending items must not appear in user B's stats");
}

private async Task<Guid> CreateOwnedBoardAsync(string stem)
{
await EnsureAuthenticatedAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,11 @@ public async Task GetQueueByStatusAsync_ShouldReturnRequests()
new LlmRequest(userId, "voicenote", "payload text")
};

_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Pending, default))
_llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Pending, default))
.ReturnsAsync(requests);

// Act
var result = await _service.GetQueueByStatusAsync(RequestStatus.Pending);
var result = await _service.GetQueueByStatusAsync(userId, RequestStatus.Pending);

// Assert
result.IsSuccess.Should().BeTrue();
Expand Down Expand Up @@ -447,17 +447,17 @@ public async Task GetQueueStatsAsync_ShouldReturnCorrectCounts()
var completedRequests = new List<LlmRequest>();
var failedRequests = new List<LlmRequest>();

_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Pending, default))
.ReturnsAsync(pendingRequests);
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Processing, default))
.ReturnsAsync(processingRequests);
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Completed, default))
.ReturnsAsync(completedRequests);
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Failed, default))
.ReturnsAsync(failedRequests);
_llmQueueRepoMock.Setup(r => r.GetStatusCountsByUserAsync(userId, default))
.ReturnsAsync(new Dictionary<RequestStatus, int>
{
{ RequestStatus.Pending, pendingRequests.Count },
{ RequestStatus.Processing, processingRequests.Count },
{ RequestStatus.Completed, completedRequests.Count },
{ RequestStatus.Failed, failedRequests.Count },
});

// Act
var result = await _service.GetQueueStatsAsync();
var result = await _service.GetQueueStatsAsync(userId);

// Assert
result.IsSuccess.Should().BeTrue();
Expand Down
Loading