Skip to content

fix: authorizationPolicyResetAll task never reaches COMPLETED; error path inverts it (#6310) - #6323

Draft
hero101 wants to merge 9 commits into
developfrom
story/6310-auth-reset-task-status
Draft

fix: authorizationPolicyResetAll task never reaches COMPLETED; error path inverts it (#6310)#6323
hero101 wants to merge 9 commits into
developfrom
story/6310-auth-reset-task-status

Conversation

@hero101

@hero101 hero101 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6310

Root cause

Two independent defects compounded into an unusable task handle:

  1. publishResetAll() created a countless task. It called taskService.create() with no itemsCount, so itemsDone stayed undefined too. The sibling publishers (publishAuthorizationResetAllAccounts etc.) already pass a count when invoked standalone — only the aggregate path, which creates one countless task and passes its id down to all of them, was broken.

  2. The two update paths disagreed on when a task is done. updateTaskErrors omitted the truthiness guard that updateTaskResults had:

    // updateTaskResults — success never completes (itemsCount falsy)
    if (task.itemsCount && task.itemsCount === task.itemsDone) 
    // updateTaskErrors — undefined === undefined → TRUE, completes on first error
    if (task.itemsCount === task.itemsDone) 

Net effect: a reset that failed got marked COMPLETED, one that succeeded stayed IN_PROGRESS forever. Any caller polling the returned task id hung indefinitely.

Confirmed pre-existing, not a regression — and not limited to auth reset: adminSearchIngestFromScratch also creates a countless task and calls updateTaskErrors mid-run, so it was silently flipping to COMPLETED on its first error too. Both are fixed by the same guard.

What changed

src/services/task/task.service.ts

  • Both update paths now share one isFullyAccountedFor() guard — a task is terminal only when it is counted and its counter has reached the target. An uncounted task is never auto-terminated; it is finished explicitly via complete() / completeWithError(), which is exactly how the search-ingest caller uses it.
  • A run that lost items settles as ERRORED, not COMPLETED, on either path (the success path checks errors.length), and a terminal task now gets its end timestamp like complete() sets one.
  • Uses >= rather than === so an over-count cannot step over the equality and hang.
  • itemsDone: itemsCount && 0 → an explicit undefined check. Behaviourally identical (&& returned the falsy left operand, correct only by accident) but readable, and it keeps the documented "no count ⇒ untracked" semantics the existing specs already encode.
  • A zero-item task is completed at creation — no item update would ever arrive to move it out of IN_PROGRESS.

src/services/auth-reset/publisher/auth-reset.service.ts

  • publishResetAll fetches the three entity id sets once up front, computes accounts × 2 + organizations × 2 + users (both the authorization and licence resets), and creates the task with that count before the first message is emitted.
  • The pre-fetched ids are handed down to the per-entity publishers via a new optional second parameter, so the messages emitted are exactly the ones counted.
  • Platform and AI-server resets are deliberately excluded from the count: they carry no task id, so the subscriber never accounts for them — counting them would leave the task 3 items short forever.

Design note: prefetch rather than COUNT(*)

The issue suggested computing the total up front. A separate COUNT(*) would drift from the subsequent SELECT (a row created or deleted in between), leaving the task permanently one item short — i.e. hanging, resurrecting the exact bug under a race. Fetching the ids once and counting those makes the count structurally trustworthy. It is also strictly cheaper: 3 id-only selects instead of the 5 the publishers each ran on their own.

The extra parameter is confined: publishResetAll is the only publisher method with a non-test caller (admin.authorization.resolver.mutations.ts), and it is called with no arguments.

Regression tests

src/services/task/task.service.spec.ts — new terminal status (issue #6310) block:

  • an uncounted task does not complete on an error (nor after many errors, nor on a result)
  • a counted task reaches COMPLETED when itemsDone reaches itemsCount
  • an early item erroring leaves the task IN_PROGRESS, and the run ends ERRORED — covering the case where the last update lands on the success path
  • an error on the last item ends ERRORED
  • counter overshoot still terminates; a zero-item task is born COMPLETED

src/services/auth-reset/publisher/auth-reset.service.spec.ts — new itemsCount (issue #6310) block:

  • the task is created with the true total (2 accounts + 3 orgs + 4 users → create(14))
  • itemsCount equals exactly the number of emitted messages that carry the task id
  • each entity id set is fetched only once

One pre-existing assertion was intentionally inverted: updateTaskErrors › should set status to COMPLETED when all items are doneshould set status to ERRORED. That test encoded the bug.

Verified non-vacuous: with the two source files reverted to develop and the new specs kept, 12 tests fail for the right reasons (expected 'completed' to be 'in-progress', expected "vi.fn()" to be called with arguments: [ 14 ], expected find to be called 3 times, but got 5).

Gate results

Gate Command Result
Tests pnpm test:ci:no:coverage 666 files passed, 7409 tests passed, 6 files / 7 tests skipped
Build pnpm build clean (nest build, no output)
Lint + typecheck pnpm lint (tsc --noEmit + biome check src/) clean — 3096 files checked, 0 errors

The full suite also ran green in the pre-commit hook on both commits.

Scope

No GraphQL schema change, no migration, no DDL — Task is a cache-backed object and both fields already existed on the DTO. Server-only; no cross-repo contract touched. Unrelated to the in-flight workspace#027-platform-role-redesign branch (which does not touch src/services/task/ or src/services/auth-reset/), which is why it ships separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Reset-all operations now track exact event counts and return the associated task ID.
    • Task progress tracking supports zero-item operations and externally assigned item counts.
    • Duplicate events are identified and counted only once.
  • Bug Fixes

    • Tasks with failed items are correctly marked as errored instead of completed.
    • Improved handling of mixed results, incomplete counts, counter overshoots, and concurrent processing.
    • Reduced redundant data retrieval during reset-all operations.
    • Task progress remains reliable when Redis is temporarily unavailable.
    • Untracked platform and AI-server events no longer affect task progress.

hero101 and others added 2 commits July 31, 2026 15:48
`updateTaskErrors` omitted the truthiness guard that `updateTaskResults`
had, so for a task created without an `itemsCount` the check read
`undefined === undefined` and flipped the task to COMPLETED on its very
first error — while a fully successful run never completed at all.

Both update paths now share one `isFullyAccountedFor()` guard: a task is
only terminal when it is *counted* and its counter has reached the target.
A run that lost items settles as ERRORED, not COMPLETED, on either path,
and a terminal task gets its `end` timestamp like `complete()` sets one.

Also replaces the obscure `itemsDone: itemsCount && 0` (which returned the
falsy left operand) with an explicit undefined check, and completes a
zero-item task at creation — no item update would ever arrive to move it
out of IN_PROGRESS.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`publishResetAll` created its task with no `itemsCount`, so the id handed
back by `authorizationPolicyResetAll` could never reach a terminal status
— useless as the completion signal our migration notes tell operators to
poll. The sibling publishers already passed a count when invoked
standalone; only the aggregate path was countless.

The three entity id sets are now fetched ONCE up front and the total
(accounts x2 + organizations x2 + users, for the authorization AND licence
resets) is set on the task before the first message is emitted. Fetching
once rather than counting separately is what makes the count trustworthy:
the ids counted are exactly the ids emitted, so a row created or deleted
between a COUNT and a SELECT cannot leave the task one item short forever.
It is also cheaper — 3 id-only selects instead of 5.

The platform and AI-server resets are deliberately excluded: they carry no
task id, so the subscriber never accounts for them.

Fixes #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 12:51
@github-actions

Copy link
Copy Markdown

📊 PR Metrics Summary

Title: fix: authorizationPolicyResetAll task never reaches COMPLETED; error path inverts it (#6310)
Total LOC Changed: 498
Files Changed: 4
Proposed Review Type: HUMAN_AUGMENTED_LLM
Rationale:

  • high_risk_keyword
  • LOC>200
  • files<=10
  • low_risk_keyword

Flags

  • High Risk Keyword
  • Low Risk Keyword
  • Composite High Risk Trigger

Thresholds

{
  "critical_loc": 200,
  "simple_loc": 100,
  "file_count": 10
}

@github-actions

Copy link
Copy Markdown

Schema Diff Summary: No blocking changes

Category Count
breaking 0
prematureRemoval 0
invalidDeprecation 0
deprecated 0
additive 0
info 0

Baseline branch: develop
Current schema MD5: 0d4505323913e76f67c7567f3e3e180a (size 319465)
Previous schema MD5: 0d4505323913e76f67c7567f3e3e180a

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes task progress tracking for long-running operations (notably authorizationPolicyResetAll) by ensuring tasks created for batch work are properly counted, and by aligning the “success” and “error” update paths so a task only becomes terminal when all counted items are accounted for.

Changes:

  • Fix TaskService.create() and task terminal-state logic so uncounted tasks never auto-complete, counted tasks complete/errored correctly, and zero-item counted tasks start as COMPLETED.
  • Update AuthResetService.publishResetAll() to prefetch entity IDs once, compute a trustworthy itemsCount, and reuse those IDs to emit exactly the tracked messages.
  • Add regression tests covering the original inversion/hanging behavior and ensuring counts match emitted work.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/services/task/task.service.ts Fixes counted vs uncounted task semantics, adds shared terminal guard, ensures terminal tasks get end timestamp, and handles overshoot/zero-item cases.
src/services/task/task.service.spec.ts Adds regression coverage for terminal status behavior (issue #6310) and updates expectations for error-terminal outcomes.
src/services/auth-reset/publisher/auth-reset.service.ts Prefetches IDs once, computes itemsCount, creates a counted task up front, and reuses IDs to keep emitted-message count consistent with tracking.
src/services/auth-reset/publisher/auth-reset.service.spec.ts Adds regression coverage to verify itemsCount correctness, message tracking alignment, and single-fetch behavior.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Task progress now uses Redis-backed atomic counters, item deduplication, and derived terminal status. Aggregate auth-reset publishing prefetches entity IDs, computes tracked item counts, and passes stable item keys through event handling.

Changes

Task accounting and terminal states

Layer / File(s) Summary
Counter infrastructure and task lifecycle
src/common/interfaces/redis.interfaces.ts, src/services/task/task.service.ts
Redis counters track task progress with an in-memory fallback and TTL refresh. Task reads derive counted-task status. Task creation handles zero-item tasks.
Item accounting and count assignment
src/services/task/task.service.ts
Result and error updates deduplicate item keys, ignore settled tasks, account failures atomically, and support setItemsCount with recount protection.
Task accounting validation
src/services/task/task.service.spec.ts, test/mocks/task.service.mock.ts
Tests cover terminal states, concurrency, stale writes, duplicate deliveries, recovery, count assignment, and zero-item tasks. Mocks expose the added task methods.

Aggregate reset publishing and event tracking

Layer / File(s) Summary
Aggregate reset orchestration and prefetched publishers
src/services/auth-reset/publisher/auth-reset.service.ts
publishResetAll prefetches IDs, computes tracked counts, reuses IDs, and marks publishing failures as errors. Entity publishers accept prefetched IDs.
Reset event item tracking
src/services/auth-reset/subscriber/auth-reset.controller.ts
Reset handlers pass full payloads. The controller derives stable item keys and forwards them to task result and error updates. Platform and AI-server events remain untracked.
Reset publishing validation
src/services/auth-reset/publisher/auth-reset.service.spec.ts
Tests verify aggregate counts, task-linked emissions, excluded event types, prefetched IDs, and query counts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: valentinyanakiev, copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the authorizationPolicyResetAll task completion bug and the inverted error path addressed by the pull request.
Linked Issues check ✅ Passed The changes satisfy #6310 by counting reset items, aligning completion guards, handling errors correctly, and covering zero-item and uncounted tasks.
Out of Scope Changes check ✅ Passed The changes remain focused on reliable task progress and reset publishing, including shared safeguards required by the reported completion defects.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch story/6310-auth-reset-task-status

Comment @coderabbitai help to get the list of available commands.

@hero101
hero101 marked this pull request as ready for review July 31, 2026 12:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/services/task/task.service.ts (1)

137-186: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Concurrent writes to itemsDone can silently lose increments, causing tasks to hang.

Both updateTaskResults and updateTaskErrors read the task from cache, increment itemsDone in memory, and write the whole task back. This read-modify-write sequence is not atomic.

The auth-reset worker runs as competing consumers across multiple pods, each pulling messages from the same RabbitMQ queue. Although prefetchCount: 1 limits each pod to one unacked message at a time, it does not prevent concurrent processing of the same task across different pods. When two pods process messages for the same task simultaneously, both can read the same itemsDone value before either writes back. The last write wins, silently discarding the increment from the other pod.

If itemsDone losses accumulate, the task never satisfies isFullyAccountedFor(), and the task remains in IN_PROGRESS indefinitely. The >= guard on line 118 defends only against over-count; it does not prevent this under-count failure.

Verify whether the task service must support concurrent updates for the same task across pods. If concurrent updates occur, implement atomic increment using Redis INCR or application-level locking. If tasks are guaranteed single-pod processing (e.g., per-task affinity in the queue), document this assumption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/task/task.service.ts` around lines 137 - 186, Ensure
updateTaskResults and updateTaskErrors update itemsDone atomically when
concurrent processing of the same task can occur across pods, using Redis INCR
or the service’s locking mechanism before persisting the task. If per-task queue
affinity guarantees single-pod processing instead, document that contract and
preserve the existing counters and completion logic.
🧹 Nitpick comments (3)
src/services/task/task.service.ts (1)

90-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse finish() for the zero-item completion path.

The zero-item branch duplicates what finish() does. If terminal-state handling changes later, this call site can drift from finish() since it does not go through it.

♻️ Proposed refactor
-    if (itemsCount === 0) {
-      task.status = TaskStatus.COMPLETED;
-      task.end = now;
-    }
+    if (itemsCount === 0) {
+      this.finish(task, TaskStatus.COMPLETED);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/task/task.service.ts` around lines 90 - 93, Update the zero-item
branch in the task completion flow to call the existing finish() method instead
of directly assigning task.status and task.end. Preserve the current behavior by
passing the appropriate completion timestamp or arguments expected by finish(),
and remove the duplicated terminal-state assignments from that branch.
src/services/auth-reset/publisher/auth-reset.service.ts (2)

91-100: 🚀 Performance & Scalability | 🔵 Trivial

Consider the memory footprint of full-table ID fetches at platform scale.

findAllIds loads every id for an entity into memory at once. For very large Account, Organization, or User tables, this could become a sizable in-memory array during a full platform reset. If dataset sizes are expected to grow substantially, consider streaming with .createQueryBuilder().stream() or paginated batches for emitResetEvents instead of one large upfront array.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/auth-reset/publisher/auth-reset.service.ts` around lines 91 -
100, Update findAllIds and its emitResetEvents caller to avoid loading every
entity ID into one in-memory array during a platform reset; use paginated
batches or a query-streaming approach to process and emit reset events
incrementally while preserving the existing ID coverage and reset behavior.

123-216: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated publish pattern into one generic helper.

publishAuthorizationResetAllAccounts, publishLicenseResetAllAccounts, publishLicenseResetAllOrganizations, publishAuthorizationResetAllUsers, and publishAuthorizationResetAllOrganizations share the same body, varying only by entity and event type. Consolidating reduces duplication and the risk of the branches drifting from each other.

♻️ Proposed refactor
+  private async publishReset<T extends ObjectLiteral & { id: string }>(
+    entity: EntityTarget<T>,
+    type: RESET_EVENT_TYPE,
+    taskId?: string,
+    ids?: string[]
+  ) {
+    const resolvedIds = ids ?? (await this.findAllIds(entity));
+
+    const task = taskId
+      ? { id: taskId }
+      : await this.taskService.create(resolvedIds.length);
+
+    this.emitResetEvents(resolvedIds, type, task.id);
+
+    return task.id;
+  }
+
   public async publishAuthorizationResetAllAccounts(
     taskId?: string,
     accountIds?: string[]
   ) {
-    const ids = accountIds ?? (await this.findAllIds(Account));
-
-    const task = taskId
-      ? { id: taskId }
-      : await this.taskService.create(ids.length);
-
-    this.emitResetEvents(
-      ids,
-      RESET_EVENT_TYPE.AUTHORIZATION_RESET_ACCOUNT,
-      task.id
-    );
-
-    return task.id;
+    return this.publishReset(
+      Account,
+      RESET_EVENT_TYPE.AUTHORIZATION_RESET_ACCOUNT,
+      taskId,
+      accountIds
+    );
   }

Apply the same pattern to the remaining four methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/auth-reset/publisher/auth-reset.service.ts` around lines 123 -
216, Extract the shared task creation and reset-event publishing flow from
publishAuthorizationResetAllAccounts, publishLicenseResetAllAccounts,
publishLicenseResetAllOrganizations, publishAuthorizationResetAllUsers, and
publishAuthorizationResetAllOrganizations into one generic helper parameterized
by the entity, optional IDs, and RESET_EVENT_TYPE. Update each public method to
delegate to that helper while preserving its current entity and event-type
mapping and returned task ID.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/services/auth-reset/publisher/auth-reset.service.ts`:
- Around line 82-88: Update the catch block in the authorization reset
initialization flow to keep the message static and pass the caught error as the
fourth BaseException argument via an ExceptionDetails object using the
originalException field.

---

Outside diff comments:
In `@src/services/task/task.service.ts`:
- Around line 137-186: Ensure updateTaskResults and updateTaskErrors update
itemsDone atomically when concurrent processing of the same task can occur
across pods, using Redis INCR or the service’s locking mechanism before
persisting the task. If per-task queue affinity guarantees single-pod processing
instead, document that contract and preserve the existing counters and
completion logic.

---

Nitpick comments:
In `@src/services/auth-reset/publisher/auth-reset.service.ts`:
- Around line 91-100: Update findAllIds and its emitResetEvents caller to avoid
loading every entity ID into one in-memory array during a platform reset; use
paginated batches or a query-streaming approach to process and emit reset events
incrementally while preserving the existing ID coverage and reset behavior.
- Around line 123-216: Extract the shared task creation and reset-event
publishing flow from publishAuthorizationResetAllAccounts,
publishLicenseResetAllAccounts, publishLicenseResetAllOrganizations,
publishAuthorizationResetAllUsers, and publishAuthorizationResetAllOrganizations
into one generic helper parameterized by the entity, optional IDs, and
RESET_EVENT_TYPE. Update each public method to delegate to that helper while
preserving its current entity and event-type mapping and returned task ID.

In `@src/services/task/task.service.ts`:
- Around line 90-93: Update the zero-item branch in the task completion flow to
call the existing finish() method instead of directly assigning task.status and
task.end. Preserve the current behavior by passing the appropriate completion
timestamp or arguments expected by finish(), and remove the duplicated
terminal-state assignments from that branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5d489311-dd59-4197-8e48-32770da57789

📥 Commits

Reviewing files that changed from the base of the PR and between a4e30c6 and fadb435.

📒 Files selected for processing (4)
  • src/services/auth-reset/publisher/auth-reset.service.spec.ts
  • src/services/auth-reset/publisher/auth-reset.service.ts
  • src/services/task/task.service.spec.ts
  • src/services/task/task.service.ts

Comment thread src/services/auth-reset/publisher/auth-reset.service.ts
… consumers

The #6310 fix made terminal status depend on itemsDone reaching itemsCount,
but the counter was a read-modify-write on a shared Redis entry. The auth-reset
queue runs as competing consumers across 10 pods, so increments were lost and
the task would never reach its target — reinstating the hang it fixed.

- itemsDone/errorCount move to atomic Redis INCR keys
- get() derives terminal status from the counters, so a stale whole-object
  write cannot clobber COMPLETED back to IN_PROGRESS
- publishResetAll: task hoisted out of the try so a partial publish fails the
  task instead of orphaning it IN_PROGRESS
- setItemsCount() lets a caller-supplied task id receive the computed total
- both update paths ignore items arriving after the task has settled

Tests fail with `expected 1 to be 10` when the atomic path is disabled.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 13:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/services/auth-reset/publisher/auth-reset.service.ts:117

  • The thrown BaseException message currently interpolates the raw caught error, which can leak sensitive runtime details into GraphQL responses and is often unhelpful when it stringifies. Keep the message stable and attach the cause via details.
      throw new BaseException(
        `Error while initializing authorization reset: ${error}`,
        LogContext.AUTH,
        AlkemioErrorStatus.AUTHORIZATION_RESET
      );

src/services/task/task.service.ts:386

  • setItemsCount() always resets itemsDone to 0 and overwrites the cached task state. If it is accidentally called on an already-counted (or already-started) task, it will silently corrupt progress and terminal status. Consider guarding against calling this on tasks that already have counters set.
  public async setItemsCount(id: string, itemsCount: number) {
    const existing = await this.getOrFail(id);

    // `itemsCount` is readonly on the interface, so rebuild rather than cast
    // the guarantee away.

src/services/auth-reset/publisher/auth-reset.service.ts:103

  • completeWithError() stores the provided string on the task and is likely operator/UI-visible; interpolating the raw caught error can leak internal details (and may stringify as "[object Object]"). Prefer a generic message here and rely on server logs / the thrown exception details for diagnostics.

This issue also appears on line 113 of the same file.

          await this.taskService.completeWithError(
            task.id,
            `Reset publishing failed before all events were emitted: ${error}`
          );

src/services/auth-reset/publisher/auth-reset.service.ts:109

  • logger.error is being called with undefined for the stacktrace argument; this drops the most useful debugging context. Pass the stack when available.
          this.logger.error(
            `Failed to mark task '${task.id}' as errored: ${completionError}`,
            undefined,
            LogContext.AUTH
          );

- BaseException carries the caught error as structured `details.originalException`
  rather than interpolating it into the message (repo convention; 9 other sites)
- completeWithError stores a stable, operator-facing string; diagnostics go to
  the log line, not to the task the `task` query exposes
- logger.error now receives the stack instead of undefined
- create() and setItemsCount() route their zero-item completion through finish(),
  so every terminal transition stamps `end` identically
- setItemsCount refuses to re-count an already-counted task, which would reset
  itemsDone under consumers that have already reported progress

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 13:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/services/task/task.service.ts (2)

381-393: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reset the Redis counters when you assign the count.

itemsDone: 0 is written to the cached object only. withAuthoritativeCounters reads task:{id}:itemsDone and task:{id}:errorCount, so a pre-existing counter value survives this reset and overrides the zero.

This is reachable today: updateTaskErrors increments errorsKey even for an uncounted task. If a caller reports an error on its own task before handing the id to publishResetAll, the stale errorCount makes the derived status ERRORED after a clean run.

Clear both keys here. This needs a del declaration added to RedisClientLike in src/common/interfaces/redis.interfaces.ts.

♻️ Sketch of the counter reset

In src/common/interfaces/redis.interfaces.ts:

   expire(
     key: string,
     seconds: number,
     callback?: (err: Error | null, res: number) => void
   ): void;
+  del(
+    key: string | string[],
+    callback?: (err: Error | null, res: number) => void
+  ): void;
 }

In src/services/task/task.service.ts:

   public async setItemsCount(id: string, itemsCount: number) {
     const existing = await this.getOrFail(id);
 
+    // The cached `itemsDone: 0` below is not authoritative — the Redis keys
+    // are. Clear them so a stale tally cannot decide the terminal status.
+    await this.resetCounters(id);
+
     // `itemsCount` is readonly on the interface, so rebuild rather than cast
     // the guarantee away.
     const task: Task = {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/task/task.service.ts` around lines 381 - 393, The setItemsCount
method resets itemsDone to 0 in the cached object but does not clear the
corresponding Redis counter keys, allowing stale values from task:{id}:itemsDone
and task:{id}:errorCount to persist and override the reset. First, add a del
method declaration to the RedisClientLike interface in
src/common/interfaces/redis.interfaces.ts to support deletion of Redis keys.
Then, in the setItemsCount method after the task object is created, use the
Redis client to delete both the task:{id}:itemsDone and task:{id}:errorCount
keys alongside the cache update to ensure counters are properly cleared when the
count is reset.

105-123: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: batch the counter reads.

Each get issues two Redis GET commands. getAll calls get for every task id, so a list of N tasks costs 2N round trips. If the task list grows, consider a single MGET of both keys per task, or one MGET for the whole list. This needs an added method on RedisClientLike.

Also applies to: 157-161

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/task/task.service.ts` around lines 105 - 123, The readCounter
method issues individual Redis GET commands for each counter key, and when
getAll iterates through multiple task IDs calling readCounter for each one, this
creates unnecessary round trips. Replace the per-task readCounter calls with a
batched approach using Redis MGET to fetch multiple counter keys in a single
operation. Add a new method to RedisClientLike that accepts multiple keys and
returns their values in one batch operation, then update getAll to collect all
needed counter keys first and fetch them together rather than calling
readCounter repeatedly for each task ID.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/services/task/task.service.ts`:
- Around line 309-334: Make task accounting idempotent per item in
updateTaskResults and updateTaskErrors by adding an item identity parameter and
tracking it in a Redis seen-items set; increment doneKey and record the
result/error only when adding the item identity is new. Update every caller,
including auth-reset.controller.ts and search.ingest.service.ts, to pass the
identity, and preserve settlement until all unique items are accounted for.

---

Nitpick comments:
In `@src/services/task/task.service.ts`:
- Around line 381-393: The setItemsCount method resets itemsDone to 0 in the
cached object but does not clear the corresponding Redis counter keys, allowing
stale values from task:{id}:itemsDone and task:{id}:errorCount to persist and
override the reset. First, add a del method declaration to the RedisClientLike
interface in src/common/interfaces/redis.interfaces.ts to support deletion of
Redis keys. Then, in the setItemsCount method after the task object is created,
use the Redis client to delete both the task:{id}:itemsDone and
task:{id}:errorCount keys alongside the cache update to ensure counters are
properly cleared when the count is reset.
- Around line 105-123: The readCounter method issues individual Redis GET
commands for each counter key, and when getAll iterates through multiple task
IDs calling readCounter for each one, this creates unnecessary round trips.
Replace the per-task readCounter calls with a batched approach using Redis MGET
to fetch multiple counter keys in a single operation. Add a new method to
RedisClientLike that accepts multiple keys and returns their values in one batch
operation, then update getAll to collect all needed counter keys first and fetch
them together rather than calling readCounter repeatedly for each task ID.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4a1e0d9b-a1ce-4741-958a-662b2c62c9b5

📥 Commits

Reviewing files that changed from the base of the PR and between fadb435 and 3096d06.

📒 Files selected for processing (6)
  • src/common/interfaces/redis.interfaces.ts
  • src/services/auth-reset/publisher/auth-reset.service.spec.ts
  • src/services/auth-reset/publisher/auth-reset.service.ts
  • src/services/task/task.service.spec.ts
  • src/services/task/task.service.ts
  • test/mocks/task.service.mock.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/services/auth-reset/publisher/auth-reset.service.ts

Comment thread src/services/task/task.service.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/services/task/task.service.ts:168

  • getOrFail throws an Error message containing the task id. Repo guidance is to keep exception messages stable (no dynamic IDs) and put dynamic data in structured details/logging instead.

This issue also appears on line 389 of the same file.


    if (!task) {
      throw new Error(`Task '${id}' not found`);
    }

src/services/task/task.service.ts:392

  • setItemsCount throws an Error message that includes the task id and existing count. Repo guidance is to keep exception messages free of dynamic data; prefer a stable message and put identifiers/counts into structured details or logs.
    if (existing.itemsCount !== undefined) {
      throw new Error(
        `Task '${id}' already has an itemsCount (${existing.itemsCount}); refusing to re-count it`
      );

Comment thread src/services/task/task.service.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/services/task/task.service.spec.ts (1)

703-715: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert that rejected re-counting preserves progress.

The test verifies that itemsCount remains 5, but it does not verify the progress value that re-counting could reset. After updateTaskResults, assert that itemsDone remains 1 after setItemsCount rejects.

Proposed test improvement
-    expect((await service.getOrFail(task.id)).itemsCount).toBe(5);
+    const persistedTask = await service.getOrFail(task.id);
+    expect(persistedTask.itemsCount).toBe(5);
+    expect(persistedTask.itemsDone).toBe(1);

As per path instructions: “Risk-based testing approach: add tests when they deliver real signal.” This assertion adds signal for the stated progress-reset regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/task/task.service.spec.ts` around lines 703 - 715, Extend the
test around setItemsCount in the “refuses to re-count a task that already has a
count” case to also assert that itemsDone remains 1 after the rejected call.
Keep the existing itemsCount assertion and rejection expectation unchanged.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/services/task/task.service.spec.ts`:
- Around line 703-715: Extend the test around setItemsCount in the “refuses to
re-count a task that already has a count” case to also assert that itemsDone
remains 1 after the rejected call. Keep the existing itemsCount assertion and
rejection expectation unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 903fd51a-851f-40f4-847f-8c161ee4b66b

📥 Commits

Reviewing files that changed from the base of the PR and between 3096d06 and e970be4.

📒 Files selected for processing (3)
  • src/services/auth-reset/publisher/auth-reset.service.ts
  • src/services/task/task.service.spec.ts
  • src/services/task/task.service.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/services/auth-reset/publisher/auth-reset.service.ts
  • src/services/task/task.service.ts

Two review findings, both real:

- Duplicate RMQ deliveries could settle a counted task early. handleReset
  re-publishes its retry before acking the original, so redelivery is
  guaranteed on pod failure; counting it twice reached itemsCount while real
  items were still queued, and the terminal guard then dropped every genuine
  update after it — a run that later failed an item still reported COMPLETED.
  Items are now claimed with SADD task:{id}:seen, keyed on the payload's
  type + entity id, and only a new claim advances the counter.
- The in-object fallback could regress the counter. If Redis was briefly
  unreachable, a reconnected INCR starts below what the task already recorded;
  a plain assignment undercounted and stranded the task IN_PROGRESS. Both the
  write path and the read overlay are now monotonic.

Only the auth-reset controller supplies an item identity; the search-ingest
call sites run on uncounted tasks, which have no target to settle early
against.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 14:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/services/task/task.service.ts:453

  • Same risk as in updateTaskResults(): writing the whole task back can clear a previously-stamped end if another consumer finished the task while this one was still processing. Since reads don’t reconstruct end, it can remain missing. Preserve an existing cached end value before writing (or move end stamping into an atomic Redis key).
    await this.cacheManager.set(task.id, task, {
      ttl: TTL,
    });

src/services/task/task.service.ts:413

  • Because each consumer overwrites the entire cached Task, a slower consumer can clobber a terminal task’s end timestamp by writing back a stale copy where end is still undefined. withAuthoritativeCounters() intentionally does not derive end, so callers may observe COMPLETED/ERRORED tasks with no end time. Consider preserving an existing end value from the cache before writing back updates (or storing end in Redis similarly to counters).

This issue also appears on line 451 of the same file.

    await this.cacheManager.set(task.id, task, {
      ttl: TTL,
    });

Every consumer writes the whole Task object back, so a slower one holding a
copy taken before the task settled would write `end: undefined` over the
stamp — leaving a COMPLETED task with no end time, since the read overlay
deliberately does not invent one.

`end` is now recorded with SETNX on task:{id}:end (first stamp wins) and
restored by the read overlay, so the clobber is no longer observable.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 14:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/services/task/task.service.ts:244

  • Exception messages should avoid embedding dynamic data (task ids) per repo guidelines. getOrFail currently throws an Error whose message includes the task id; prefer a stable message and attach the id via a details property instead.

This issue also appears on line 511 of the same file.

  public async getOrFail(id: string) {
    const task = await this.get(id);

    if (!task) {
      throw new Error(`Task '${id}' not found`);
    }

src/services/task/task.service.ts:515

  • Exception messages should avoid embedding dynamic data. This error includes both the task id and the existing count in the message; consider keeping the message stable and attaching taskId/itemsCount via a details property instead.
    if (existing.itemsCount !== undefined) {
      throw new Error(
        `Task '${id}' already has an itemsCount (${existing.itemsCount}); refusing to re-count it`
      );
    }

src/services/task/task.service.ts:153

  • The JSDoc block starting with "Claim an item for this task" is immediately followed by another JSDoc block, so the first one becomes an orphan comment (not attached to any symbol). This is confusing for readers and for generated docs; consider moving that block to sit directly above claimItem(), and keep only the stampEnd() doc above stampEnd().
  /**
   * Claim an item for this task, exactly once.
   *
   * RabbitMQ is at-least-once, and `handleReset` re-publishes its retry BEFORE
   * acking the original — so a pod dying between those two lines guarantees a
   * redelivery. Counting that twice would push `itemsDone` to `itemsCount`
   * while real items are still queued: the task settles early, and every
   * genuine update after it is dropped by the terminal guard, so a run that
   * later fails an item can still report COMPLETED.
   *
   * `SADD` returns 1 only for a member that was not already in the set, which
   * makes it the claim. Returns true when the caller owns this item and should
   * account for it, false when it is a duplicate.
   *
   * Without Redis (or without an item identity) there is nothing to dedupe
   * against and every call is treated as a fresh item — the previous
   * behaviour.
   */
  /**
   * Record the task's terminal timestamp where it cannot be clobbered.
   *
   * `end` lives on the Task object, and every consumer writes that whole
   * object back — so a slower consumer holding a copy taken before the task
   * settled will write `end: undefined` straight over the stamp. The result is
   * a COMPLETED task with no end time. SETNX keeps the FIRST stamp, and the
   * read overlay restores it, so the clobber becomes invisible.
   */

src/services/auth-reset/publisher/auth-reset.service.ts:127

  • BaseException includes details in GraphQL error extensions; passing the raw caught error as originalException risks leaking internal exception data to API consumers and may not serialize cleanly. Consider omitting the raw exception from details (you already log stacktrace), or include only a stable, non-sensitive cause string.
      throw new BaseException(
        'Error while initializing authorization reset',
        LogContext.AUTH,
        AlkemioErrorStatus.AUTHORIZATION_RESET,
        { originalException: error }
      );

stampEnd was inserted between claimItem's doc block and claimItem itself,
leaving two stacked JSDoc comments above stampEnd and the claim semantics
documented on the wrong method.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/services/auth-reset/publisher/auth-reset.service.ts
complete()/completeWithError() set the terminal status on the cached object,
but a consumer that read the task just before them still holds an IN_PROGRESS
copy and writes it straight back. For a task failed *because publishing threw*
this is unrecoverable: the remaining events were never emitted, so the counter
can never reach itemsCount and the counter-derived path has nothing to repair
it with — the task hangs, which is #6310 again by another route.

The terminal status is now stamped with SETNX on task:{id}:terminal (first
stamp wins) and reapplied by the read overlay, alongside the existing end
stamp. The overlay returns the task by identity when there is no Redis, so
single-process callers keep mutating the instance they write back.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 14:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/services/task/task.service.ts
setItemsCount accepted any number. A fractional target can never be reached by
an integer counter, and a negative one satisfies `itemsDone >= itemsCount` on
the first read — so the task would report finished having processed nothing.

Refs #6310

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 14:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/services/task/task.service.ts:452

  • accountForItem() assumes atomic === undefined only happens when there is no Redis client, but incrementCounter() also resolves undefined on Redis INCR/EXPIRE errors. That makes the surrounding comment inaccurate and can mislead future maintainers about when the non-atomic fallback path can occur (including in multi-pod/competing-consumer scenarios).
    // `atomic` is undefined only when there is no Redis to be atomic against —
    // a single-process environment, where the in-object increment is exact.
    //
    // Math.max, not a plain assignment: if Redis was briefly unreachable some
    // increments landed in-object only, so a reconnected INCR starts from a

@hero101
hero101 marked this pull request as draft July 31, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: authorizationPolicyResetAll task never reaches COMPLETED; error path inverts it

2 participants