fix(ai): de-duplicate the categorization backfill per user
Raising retry_after stops one dispatch from being re-reserved, but it does nothing against a *duplicate* dispatch. The ai queue runs two concurrent workers, so a double "Enable AI" click (or re-enabling consent mid-run) launches two backfills that read the same pending snapshot and both bill the model — the very harm tries=1 was meant to avoid but structurally cannot. Implement ShouldBeUnique keyed on the user id, mirroring the sibling RetryTransientAiCategorizationJob, so a user has at most one backfill in flight. Add a test anchoring the dedup key.
This commit is contained in:
parent
e7e319a5b8
commit
6f7c60a8fe
|
|
@ -5,6 +5,7 @@ namespace App\Jobs;
|
|||
use App\Models\User;
|
||||
use App\Services\Ai\AiCategorizationGate;
|
||||
use App\Services\Ai\AiCategorizer;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -15,11 +16,17 @@ use Throwable;
|
|||
* consent outside of onboarding. Progress is written to the cache so the
|
||||
* transactions page can poll it and surface live progress while the batch runs.
|
||||
*
|
||||
* De-duplicated per user: a second dispatch (double "Enable AI" click, or
|
||||
* re-enabling consent while a run is still in flight) would read the same
|
||||
* pending-transaction snapshot on a concurrent worker and re-bill the model for
|
||||
* work already underway — the exact harm tries=1 targets but cannot prevent,
|
||||
* since tries only bounds re-attempts of one dispatch, not duplicate dispatches.
|
||||
*
|
||||
* ponytail: mirrors CategorizeOnboardingTransactionsJob's selection + chunking;
|
||||
* kept separate so the onboarding pass stays progress-free. Fold the two
|
||||
* together if a third caller ever needs the same loop.
|
||||
*/
|
||||
class CategorizeUncategorizedTransactionsJob implements ShouldQueue
|
||||
class CategorizeUncategorizedTransactionsJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
@ -34,8 +41,19 @@ class CategorizeUncategorizedTransactionsJob implements ShouldQueue
|
|||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Safety TTL for the unique lock in case a worker dies mid-run; comfortably
|
||||
* longer than a full run.
|
||||
*/
|
||||
public int $uniqueFor = 1800;
|
||||
|
||||
public function __construct(public User $user, public string $jobId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->user->id;
|
||||
}
|
||||
|
||||
public function viaQueue(): string
|
||||
{
|
||||
return (string) config('ai_categorization.queue');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Models\Transaction;
|
|||
use App\Models\User;
|
||||
use App\Services\Ai\CategorizeTransactions;
|
||||
use App\Services\Ai\CategoryCatalog;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
|
|
@ -218,3 +219,11 @@ it('categorizes the most recent transactions first', function () {
|
|||
|
||||
expect($order)->toBe([$newest->id, $middle->id, $oldest->id]);
|
||||
});
|
||||
|
||||
it('de-duplicates the backfill per user so a concurrent dispatch cannot double-bill', function () {
|
||||
$user = User::factory()->create();
|
||||
$job = new CategorizeUncategorizedTransactionsJob($user, 'job-x');
|
||||
|
||||
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
|
||||
->and($job->uniqueId())->toBe($user->id);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue