From b2a010dd294cf17bc38ba60c63b0f66d71cf7d2c Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:51:28 +0000 Subject: [PATCH 1/5] refactor(jobs): record task persistence through a single QuantumTaskRecorder SubmitQuantumCircuit::persistSubmission() and PollQuantumTask::persist() repeated the same shape: return early unless aether.persist_tasks is on, try a write, report() and swallow any Throwable. Only the write differed. Tasks\QuantumTaskRecorder now owns that guard and safety net once, with recordSubmission() for the insert and recordProgress() for the status, counts and error mirror. Both jobs resolve it from the container and call it, so the persistence rules live in one class with their own tests. Closes #58 --- src/Jobs/PollQuantumTask.php | 61 ++------ src/Jobs/SubmitQuantumCircuit.php | 36 +---- src/Tasks/QuantumTaskRecorder.php | 101 +++++++++++++ .../Feature/Tasks/QuantumTaskRecorderTest.php | 140 ++++++++++++++++++ 4 files changed, 257 insertions(+), 81 deletions(-) create mode 100644 src/Tasks/QuantumTaskRecorder.php create mode 100644 tests/Feature/Tasks/QuantumTaskRecorderTest.php diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index 9a4c0b2..f507163 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -9,10 +9,9 @@ use Aether\Events\CircuitCompleted; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; -use Aether\Models\QuantumTask; use Aether\QuantumManager; use Aether\Results\CircuitResult; -use Aether\Tasks\TaskStatus; +use Aether\Tasks\QuantumTaskRecorder; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -80,16 +79,21 @@ public function handle(QuantumManager $manager, Dispatcher $events): void $snapshot = $device->checkTask($this->taskArn); + // The recorder mirrors the backend status onto the quantum_tasks row + // (when persistence is on) and swallows database failures, so it can + // never fail the job or suppress the CircuitCompleted event below. + $recorder = app(QuantumTaskRecorder::class); + if (! $snapshot->status->isTerminal()) { $maxAttempts = $this->tries(); if ($this->attempts() >= $maxAttempts) { $e = QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts()); - $this->persist($snapshot->status, null, $e->getMessage()); + $recorder->recordProgress($this->taskArn, $snapshot->status, null, $e->getMessage()); throw $e; } - $this->persist($snapshot->status); + $recorder->recordProgress($this->taskArn, $snapshot->status); $this->release((int) config('aether.poll_interval', 5)); return; @@ -97,7 +101,7 @@ public function handle(QuantumManager $manager, Dispatcher $events): void if (! $snapshot->status->isSuccessful()) { $e = TaskFailedException::forTask($this->taskArn, $snapshot->status); - $this->persist($snapshot->status, null, $e->getMessage()); + $recorder->recordProgress($this->taskArn, $snapshot->status, null, $e->getMessage()); throw $e; } @@ -106,11 +110,11 @@ public function handle(QuantumManager $manager, Dispatcher $events): void 'checkTask', "task [{$this->taskArn}] completed but returned no measurement counts." ); - $this->persist($snapshot->status, null, $e->getMessage()); + $recorder->recordProgress($this->taskArn, $snapshot->status, null, $e->getMessage()); throw $e; } - $this->persist($snapshot->status, $snapshot->counts); + $recorder->recordProgress($this->taskArn, $snapshot->status, $snapshot->counts); $events->dispatch(new CircuitCompleted( $driverName, @@ -119,47 +123,4 @@ public function handle(QuantumManager $manager, Dispatcher $events): void $this->taskArn, )); } - - /** - * Mirror the backend state onto the persisted quantum_tasks row, when - * persistence is enabled. - * - * The status column always reflects what the backend last reported; our - * own polling problems (exhausted budget, malformed response) only ever - * populate error and failed_at. Persistence is best-effort: a database - * failure is reported and swallowed so it can never fail the job or - * suppress the CircuitCompleted event. - * - * @param array|null $counts - */ - private function persist(TaskStatus $status, ?array $counts = null, ?string $error = null): void - { - if (! config('aether.persist_tasks', false)) { - return; - } - - try { - $task = QuantumTask::query()->where('task_arn', $this->taskArn)->first(); - - if ($task === null) { - return; - } - - $task->status = $status; - - if ($counts !== null) { - $task->counts = $counts; - $task->completed_at = now(); - } - - if ($error !== null) { - $task->error = $error; - $task->failed_at = now(); - } - - $task->save(); - } catch (\Throwable $e) { - report($e); - } - } } diff --git a/src/Jobs/SubmitQuantumCircuit.php b/src/Jobs/SubmitQuantumCircuit.php index 539ded3..69ee0c1 100644 --- a/src/Jobs/SubmitQuantumCircuit.php +++ b/src/Jobs/SubmitQuantumCircuit.php @@ -8,9 +8,8 @@ use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\QuantumDevice; use Aether\Exceptions\QuantumExecutionException; -use Aether\Models\QuantumTask; use Aether\QuantumManager; -use Aether\Tasks\TaskStatus; +use Aether\Tasks\QuantumTaskRecorder; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -63,37 +62,12 @@ public function handle(QuantumManager $manager): void $taskArn = $device->submitCircuit($builder); - $this->persistSubmission($taskArn, $driverName); + // Best-effort by design: the remote task already exists at this point, + // so the recorder reports and swallows a database failure rather than + // letting the job retry and submit a second billable task. + app(QuantumTaskRecorder::class)->recordSubmission($taskArn, $driverName, $this->circuit); PollQuantumTask::dispatch($taskArn, $this->circuit, $this->driver) ->delay((int) config('aether.poll_interval', 5)); } - - /** - * Record the submitted task in the quantum_tasks table, when persistence - * is enabled. - * - * Best-effort by design: the remote task already exists at this point, so - * a database failure is reported and swallowed rather than allowed to - * retry the job and submit a second billable task. - */ - private function persistSubmission(string $taskArn, string $driverName): void - { - if (! config('aether.persist_tasks', false)) { - return; - } - - try { - QuantumTask::query()->create([ - 'task_arn' => $taskArn, - 'driver' => $driverName, - 'status' => TaskStatus::Created, - 'circuit' => $this->circuit, - 'shots' => $this->circuit['shots'], - 'submitted_at' => now(), - ]); - } catch (\Throwable $e) { - report($e); - } - } } diff --git a/src/Tasks/QuantumTaskRecorder.php b/src/Tasks/QuantumTaskRecorder.php new file mode 100644 index 0000000..b33531e --- /dev/null +++ b/src/Tasks/QuantumTaskRecorder.php @@ -0,0 +1,101 @@ +>, shots: int} $circuit The CircuitBuilder::toArray() payload that was submitted. + */ + public function recordSubmission(string $taskArn, string $driver, array $circuit): void + { + $this->write(static function () use ($taskArn, $driver, $circuit): void { + QuantumTask::query()->create([ + 'task_arn' => $taskArn, + 'driver' => $driver, + 'status' => TaskStatus::Created, + 'circuit' => $circuit, + 'shots' => $circuit['shots'], + 'submitted_at' => now(), + ]); + }); + } + + /** + * Mirror the state the backend last reported onto the task's row. + * + * The status column always reflects the backend; our own polling problems + * (exhausted budget, malformed response) only ever populate error and + * failed_at. A task that was never recorded (persistence enabled after + * submission, or the insert failed) is left alone. + * + * @param array|null $counts + */ + public function recordProgress(string $taskArn, TaskStatus $status, ?array $counts = null, ?string $error = null): void + { + $this->write(static function () use ($taskArn, $status, $counts, $error): void { + $task = QuantumTask::query()->where('task_arn', $taskArn)->first(); + + if ($task === null) { + return; + } + + $task->status = $status; + + if ($counts !== null) { + $task->counts = $counts; + $task->completed_at = now(); + } + + if ($error !== null) { + $task->error = $error; + $task->failed_at = now(); + } + + $task->save(); + }); + } + + /** + * Whether tasks are persisted at all (`aether.persist_tasks`). + */ + public function enabled(): bool + { + return (bool) config('aether.persist_tasks', false); + } + + /** + * Run a write when persistence is enabled, reporting and swallowing any + * failure so the caller's job is never affected. + * + * @param \Closure(): void $write + */ + private function write(\Closure $write): void + { + if (! $this->enabled()) { + return; + } + + try { + $write(); + } catch (\Throwable $e) { + report($e); + } + } +} diff --git a/tests/Feature/Tasks/QuantumTaskRecorderTest.php b/tests/Feature/Tasks/QuantumTaskRecorderTest.php new file mode 100644 index 0000000..89620ad --- /dev/null +++ b/tests/Feature/Tasks/QuantumTaskRecorderTest.php @@ -0,0 +1,140 @@ +loadMigrationsFrom(__DIR__.'/../../../database/migrations'); + config()->set('aether.persist_tasks', true); + + $this->recorder = app(QuantumTaskRecorder::class); + $this->circuit = ['qubits' => 2, 'gates' => [], 'shots' => 500]; +}); + +// ------------------------------------------------------------------------- +// Gate +// ------------------------------------------------------------------------- + +it('reflects the persist_tasks setting', function () { + expect($this->recorder->enabled())->toBeTrue(); + + config()->set('aether.persist_tasks', false); + + expect($this->recorder->enabled())->toBeFalse(); +}); + +it('runs no query at all when persistence is disabled', function () { + config()->set('aether.persist_tasks', false); + + DB::enableQueryLog(); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['00' => 500]); + + expect(DB::getQueryLog())->toBeEmpty(); + $this->assertDatabaseCount('quantum_tasks', 0); +}); + +// ------------------------------------------------------------------------- +// recordSubmission() +// ------------------------------------------------------------------------- + +it('records a submitted task as created with its circuit, driver and shots', function () { + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + + $task = QuantumTask::query()->firstOrFail(); + + expect($task->task_arn)->toBe('arn:1') + ->and($task->driver)->toBe('aws') + ->and($task->status)->toBe(TaskStatus::Created) + ->and($task->circuit)->toBe($this->circuit) + ->and($task->shots)->toBe(500) + ->and($task->submitted_at)->not->toBeNull() + ->and($task->counts)->toBeNull() + ->and($task->completed_at)->toBeNull() + ->and($task->failed_at)->toBeNull() + ->and($task->error)->toBeNull(); +}); + +// ------------------------------------------------------------------------- +// recordProgress() +// ------------------------------------------------------------------------- + +it('mirrors an intermediate status without touching the outcome columns', function () { + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + + $this->recorder->recordProgress('arn:1', TaskStatus::Running); + + $task = QuantumTask::query()->firstOrFail(); + + expect($task->status)->toBe(TaskStatus::Running) + ->and($task->counts)->toBeNull() + ->and($task->completed_at)->toBeNull() + ->and($task->failed_at)->toBeNull() + ->and($task->error)->toBeNull(); +}); + +it('records counts and the completion time on success', function () { + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + + $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['00' => 250, '11' => 250]); + + $task = QuantumTask::query()->firstOrFail(); + + expect($task->status)->toBe(TaskStatus::Completed) + ->and($task->counts)->toBe(['00' => 250, '11' => 250]) + ->and($task->completed_at)->not->toBeNull() + ->and($task->failed_at)->toBeNull() + ->and($task->error)->toBeNull(); +}); + +it('records the error and the failure time while keeping the backend status', function () { + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + + $this->recorder->recordProgress('arn:1', TaskStatus::Running, null, 'polling exhausted'); + + $task = QuantumTask::query()->firstOrFail(); + + expect($task->status)->toBe(TaskStatus::Running) + ->and($task->error)->toBe('polling exhausted') + ->and($task->failed_at)->not->toBeNull() + ->and($task->completed_at)->toBeNull() + ->and($task->counts)->toBeNull(); +}); + +it('leaves a task that was never recorded alone', function () { + $this->recorder->recordProgress('arn:unknown', TaskStatus::Completed, ['0' => 1]); + + $this->assertDatabaseCount('quantum_tasks', 0); +}); + +// ------------------------------------------------------------------------- +// Failure handling +// ------------------------------------------------------------------------- + +it('reports and swallows a database failure on either write', function () { + Schema::dropIfExists('quantum_tasks'); + + $reported = []; + $handler = Mockery::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->twice()->andReturnUsing(function (Throwable $e) use (&$reported): void { + $reported[] = $e; + }); + app()->instance(ExceptionHandler::class, $handler); + + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['0' => 1]); + + expect($reported)->toHaveCount(2) + ->and($reported[0])->toBeInstanceOf(QueryException::class) + ->and($reported[1])->toBeInstanceOf(QueryException::class); +}); From 070860ebd29923163ff4150e76af8cc9ee7866d7 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:51:52 +0000 Subject: [PATCH 2/5] docs: note the QuantumTaskRecorder convention in CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..8f90b4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Quantum (Facade) - **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). - **QPU safety:** Drivers with `synchronous_safe: false` throw on `->run()` to prevent HTTP timeouts. - **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo. +- **Task persistence goes through `Tasks\QuantumTaskRecorder`:** both jobs call `recordSubmission()` / `recordProgress()`; the recorder alone checks `aether.persist_tasks` and reports-and-swallows database failures, so a job never repeats that guard or try/catch. ## Config From 2c0c4e6a1c2f131fbc73cb7614fd1b8d0335bfb3 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:56:58 +0000 Subject: [PATCH 3/5] refactor(jobs): inject QuantumTaskRecorder through handle() and thin the duplicated persistence tests The recorder is now a handle() dependency like QuantumManager and the event Dispatcher, instead of being located with app() inside the method. The two PersistenceTest cases that only exercised the persist_tasks gate are gone: that rule is pinned by the recorder's own tests, and PersistenceTest keeps the job-level cases (what each job records, and when). --- src/Jobs/PollQuantumTask.php | 4 +-- src/Jobs/SubmitQuantumCircuit.php | 4 +-- tests/Feature/AsynchronousExecutionTest.php | 4 ++- tests/Feature/Jobs/PollQuantumTaskTest.php | 15 +++++----- .../Feature/Jobs/SubmitQuantumCircuitTest.php | 7 +++-- tests/Feature/PersistenceTest.php | 28 ++++--------------- 6 files changed, 24 insertions(+), 38 deletions(-) diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index f507163..bf13807 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -68,7 +68,7 @@ public function tries(): int /** * Execute the job. */ - public function handle(QuantumManager $manager, Dispatcher $events): void + public function handle(QuantumManager $manager, Dispatcher $events, QuantumTaskRecorder $recorder): void { $driverName = $this->driver ?? config('aether.default', 'local'); $device = $manager->driver($this->driver); @@ -82,8 +82,6 @@ public function handle(QuantumManager $manager, Dispatcher $events): void // The recorder mirrors the backend status onto the quantum_tasks row // (when persistence is on) and swallows database failures, so it can // never fail the job or suppress the CircuitCompleted event below. - $recorder = app(QuantumTaskRecorder::class); - if (! $snapshot->status->isTerminal()) { $maxAttempts = $this->tries(); diff --git a/src/Jobs/SubmitQuantumCircuit.php b/src/Jobs/SubmitQuantumCircuit.php index 69ee0c1..26b7cc1 100644 --- a/src/Jobs/SubmitQuantumCircuit.php +++ b/src/Jobs/SubmitQuantumCircuit.php @@ -49,7 +49,7 @@ public function __construct( /** * Execute the job. */ - public function handle(QuantumManager $manager): void + public function handle(QuantumManager $manager, QuantumTaskRecorder $recorder): void { $driverName = $this->driver ?? config('aether.default', 'local'); $device = $manager->driver($this->driver); @@ -65,7 +65,7 @@ public function handle(QuantumManager $manager): void // Best-effort by design: the remote task already exists at this point, // so the recorder reports and swallows a database failure rather than // letting the job retry and submit a second billable task. - app(QuantumTaskRecorder::class)->recordSubmission($taskArn, $driverName, $this->circuit); + $recorder->recordSubmission($taskArn, $driverName, $this->circuit); PollQuantumTask::dispatch($taskArn, $this->circuit, $this->driver) ->delay((int) config('aether.poll_interval', 5)); diff --git a/tests/Feature/AsynchronousExecutionTest.php b/tests/Feature/AsynchronousExecutionTest.php index 572da08..b850468 100644 --- a/tests/Feature/AsynchronousExecutionTest.php +++ b/tests/Feature/AsynchronousExecutionTest.php @@ -10,6 +10,7 @@ use Aether\Jobs\SubmitQuantumCircuit; use Aether\QuantumManager; use Aether\Results\CircuitResult; +use Aether\Tasks\QuantumTaskRecorder; use Aether\Tasks\TaskStatus; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Facades\Bus; @@ -51,7 +52,7 @@ $circuit = Quantum::circuit('local')->qubits(1)->h(0)->measure()->shots(100); // Stage one: the submission job hands the task off to the backend. - (new SubmitQuantumCircuit($circuit->toArray(), 'local'))->handle(app(QuantumManager::class)); + (new SubmitQuantumCircuit($circuit->toArray(), 'local'))->handle(app(QuantumManager::class), app(QuantumTaskRecorder::class)); $arn = null; @@ -65,6 +66,7 @@ (new PollQuantumTask($arn, $circuit->toArray(), 'local'))->handle( app(QuantumManager::class), app(Dispatcher::class), + app(QuantumTaskRecorder::class), ); Event::assertDispatched(CircuitCompleted::class, function (CircuitCompleted $event) use ($arn): bool { diff --git a/tests/Feature/Jobs/PollQuantumTaskTest.php b/tests/Feature/Jobs/PollQuantumTaskTest.php index e9222c2..b9b2cb0 100644 --- a/tests/Feature/Jobs/PollQuantumTaskTest.php +++ b/tests/Feature/Jobs/PollQuantumTaskTest.php @@ -7,6 +7,7 @@ use Aether\Exceptions\TaskFailedException; use Aether\Jobs\PollQuantumTask; use Aether\QuantumManager; +use Aether\Tasks\QuantumTaskRecorder; use Aether\Tasks\TaskSnapshot; use Aether\Tasks\TaskStatus; use Aether\Tests\Feature\Jobs\FakeAsynchronousDevice; @@ -39,7 +40,7 @@ $manager->extend('fake-async', fn () => $device); $job = (new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'))->withFakeQueueInteractions(); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); $job->assertReleased(delay: 3); }); @@ -62,7 +63,7 @@ $job->setJob($mockJob); try { - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); $this->fail('Expected QuantumExecutionException to be thrown.'); } catch (QuantumExecutionException $exception) { expect($exception->getMessage())->toContain($device->taskArnToReturn); @@ -78,7 +79,7 @@ $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); })->with([TaskStatus::Failed, TaskStatus::Cancelled])->throws(TaskFailedException::class); it('dispatches CircuitCompleted with the counts and task arn once completed', function () { @@ -92,7 +93,7 @@ $circuit = ['qubits' => 2, 'gates' => [], 'shots' => 10]; $job = new PollQuantumTask($device->taskArnToReturn, $circuit, 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); Event::assertDispatched( CircuitCompleted::class, @@ -114,7 +115,7 @@ $manager->extend('fake-async', fn () => $device); $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 1, 'gates' => [], 'shots' => 1]); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); Event::assertDispatched( CircuitCompleted::class, @@ -131,7 +132,7 @@ $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); })->throws(QuantumExecutionException::class); it('throws asynchronousUnsupported when the resolved driver does not support async execution', function () { @@ -140,5 +141,5 @@ $manager->extend('fake-sync', fn () => $device); $job = new PollQuantumTask('arn:fake', ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); })->throws(QuantumExecutionException::class); diff --git a/tests/Feature/Jobs/SubmitQuantumCircuitTest.php b/tests/Feature/Jobs/SubmitQuantumCircuitTest.php index c662632..e37611b 100644 --- a/tests/Feature/Jobs/SubmitQuantumCircuitTest.php +++ b/tests/Feature/Jobs/SubmitQuantumCircuitTest.php @@ -7,6 +7,7 @@ use Aether\Jobs\PollQuantumTask; use Aether\Jobs\SubmitQuantumCircuit; use Aether\QuantumManager; +use Aether\Tasks\QuantumTaskRecorder; use Aether\Tests\Feature\Jobs\FakeAsynchronousDevice; use Aether\Tests\Feature\Jobs\FakeSynchronousOnlyDevice; use Illuminate\Support\Facades\Queue; @@ -21,7 +22,7 @@ $manager->extend('fake-async', fn () => $device); $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager); + $job->handle($manager, app(QuantumTaskRecorder::class)); expect($device->submittedCircuits)->toHaveCount(1) ->and($device->submittedCircuits[0])->toBeInstanceOf(CircuitBuilder::class); @@ -40,7 +41,7 @@ $manager->extend('fake-sync', fn () => $device); $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); - $job->handle($manager); + $job->handle($manager, app(QuantumTaskRecorder::class)); })->throws(QuantumExecutionException::class); it('mentions the unsupported driver name in the exception message', function () { @@ -51,7 +52,7 @@ $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); try { - $job->handle($manager); + $job->handle($manager, app(QuantumTaskRecorder::class)); $this->fail('Expected QuantumExecutionException to be thrown.'); } catch (QuantumExecutionException $exception) { expect($exception->getMessage())->toContain('fake-sync'); diff --git a/tests/Feature/PersistenceTest.php b/tests/Feature/PersistenceTest.php index 34e4bc4..36debb4 100644 --- a/tests/Feature/PersistenceTest.php +++ b/tests/Feature/PersistenceTest.php @@ -9,17 +9,20 @@ use Aether\Jobs\SubmitQuantumCircuit; use Aether\Models\QuantumTask; use Aether\QuantumManager; +use Aether\Tasks\QuantumTaskRecorder; use Aether\Tasks\TaskSnapshot; use Aether\Tasks\TaskStatus; use Aether\Tests\Feature\Jobs\FakeAsynchronousDevice; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\Job; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Schema; +// End-to-end coverage of the jobs driving QuantumTaskRecorder: what each job +// records and when. The persistence rules themselves (the persist_tasks gate, +// report-and-swallow) are pinned in tests/Feature/Tasks/QuantumTaskRecorderTest. uses(RefreshDatabase::class); beforeEach(function () { @@ -39,7 +42,7 @@ // Submit through the real job, then hand back the poll job it queued so // each test can drive the polling state machine directly. $this->submit = function (): PollQuantumTask { - (new SubmitQuantumCircuit($this->circuit, 'fake-async'))->handle($this->manager); + (new SubmitQuantumCircuit($this->circuit, 'fake-async'))->handle($this->manager, app(QuantumTaskRecorder::class)); $pollJob = null; Queue::assertPushed(PollQuantumTask::class, function (PollQuantumTask $job) use (&$pollJob) { @@ -51,7 +54,7 @@ return $pollJob; }; - $this->poll = fn (PollQuantumTask $job) => $job->handle($this->manager, app(Dispatcher::class)); + $this->poll = fn (PollQuantumTask $job) => $job->handle($this->manager, app(Dispatcher::class), app(QuantumTaskRecorder::class)); }); // ------------------------------------------------------------------------- @@ -77,14 +80,6 @@ ->and($task->error)->toBeNull(); }); -it('does not record anything when persist_tasks is disabled', function () { - config()->set('aether.persist_tasks', false); - - ($this->submit)(); - - $this->assertDatabaseCount('quantum_tasks', 0); -}); - it('still dispatches the poll job when the insert fails', function () { Schema::dropIfExists('quantum_tasks'); @@ -98,17 +93,6 @@ // Polling // ------------------------------------------------------------------------- -it('runs no query at all from the poll job when persist_tasks is disabled', function () { - config()->set('aether.persist_tasks', false); - $job = ($this->submit)(); - - DB::enableQueryLog(); - ($this->poll)($job); - - expect(DB::getQueryLog())->toBeEmpty(); - Event::assertDispatched(CircuitCompleted::class); -}); - it('marks the task completed with its counts', function () { $job = ($this->submit)(); From 8c68d0a479e8270dcd64d5dcfce540db9f4b715e Mon Sep 17 00:00:00 2001 From: corgab Date: Thu, 10 Sep 2026 13:21:45 +0200 Subject: [PATCH 4/5] Apply review feedback: pass shots explicitly to avoid array offset errors --- src/Jobs/SubmitQuantumCircuit.php | 2 +- src/Tasks/QuantumTaskRecorder.php | 6 +++--- tests/Feature/PersistenceTest.php | 4 +++- .../Feature/Tasks/QuantumTaskRecorderTest.php | 20 ++++++++++--------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/Jobs/SubmitQuantumCircuit.php b/src/Jobs/SubmitQuantumCircuit.php index 26b7cc1..496d50f 100644 --- a/src/Jobs/SubmitQuantumCircuit.php +++ b/src/Jobs/SubmitQuantumCircuit.php @@ -65,7 +65,7 @@ public function handle(QuantumManager $manager, QuantumTaskRecorder $recorder): // Best-effort by design: the remote task already exists at this point, // so the recorder reports and swallows a database failure rather than // letting the job retry and submit a second billable task. - $recorder->recordSubmission($taskArn, $driverName, $this->circuit); + $recorder->recordSubmission($taskArn, $driverName, $this->circuit, $this->circuit['shots'] ?? 0); PollQuantumTask::dispatch($taskArn, $this->circuit, $this->driver) ->delay((int) config('aether.poll_interval', 5)); diff --git a/src/Tasks/QuantumTaskRecorder.php b/src/Tasks/QuantumTaskRecorder.php index b33531e..5f329c4 100644 --- a/src/Tasks/QuantumTaskRecorder.php +++ b/src/Tasks/QuantumTaskRecorder.php @@ -23,15 +23,15 @@ class QuantumTaskRecorder * * @param array{qubits: int, gates: array>, shots: int} $circuit The CircuitBuilder::toArray() payload that was submitted. */ - public function recordSubmission(string $taskArn, string $driver, array $circuit): void + public function recordSubmission(string $taskArn, string $driver, array $circuit, int $shots): void { - $this->write(static function () use ($taskArn, $driver, $circuit): void { + $this->write(static function () use ($taskArn, $driver, $circuit, $shots): void { QuantumTask::query()->create([ 'task_arn' => $taskArn, 'driver' => $driver, 'status' => TaskStatus::Created, 'circuit' => $circuit, - 'shots' => $circuit['shots'], + 'shots' => $shots, 'submitted_at' => now(), ]); }); diff --git a/tests/Feature/PersistenceTest.php b/tests/Feature/PersistenceTest.php index 36debb4..cabc919 100644 --- a/tests/Feature/PersistenceTest.php +++ b/tests/Feature/PersistenceTest.php @@ -81,7 +81,9 @@ }); it('still dispatches the poll job when the insert fails', function () { - Schema::dropIfExists('quantum_tasks'); + QuantumTask::saving(function () { + throw new \RuntimeException('Simulated database failure'); + }); ($this->submit)(); diff --git a/tests/Feature/Tasks/QuantumTaskRecorderTest.php b/tests/Feature/Tasks/QuantumTaskRecorderTest.php index 89620ad..6398ed9 100644 --- a/tests/Feature/Tasks/QuantumTaskRecorderTest.php +++ b/tests/Feature/Tasks/QuantumTaskRecorderTest.php @@ -37,7 +37,7 @@ config()->set('aether.persist_tasks', false); DB::enableQueryLog(); - $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit, 500); $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['00' => 500]); expect(DB::getQueryLog())->toBeEmpty(); @@ -49,7 +49,7 @@ // ------------------------------------------------------------------------- it('records a submitted task as created with its circuit, driver and shots', function () { - $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit, 500); $task = QuantumTask::query()->firstOrFail(); @@ -70,7 +70,7 @@ // ------------------------------------------------------------------------- it('mirrors an intermediate status without touching the outcome columns', function () { - $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit, 500); $this->recorder->recordProgress('arn:1', TaskStatus::Running); @@ -84,7 +84,7 @@ }); it('records counts and the completion time on success', function () { - $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit, 500); $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['00' => 250, '11' => 250]); @@ -98,7 +98,7 @@ }); it('records the error and the failure time while keeping the backend status', function () { - $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit, 500); $this->recorder->recordProgress('arn:1', TaskStatus::Running, null, 'polling exhausted'); @@ -122,7 +122,9 @@ // ------------------------------------------------------------------------- it('reports and swallows a database failure on either write', function () { - Schema::dropIfExists('quantum_tasks'); + QuantumTask::saving(function () { + throw new \RuntimeException('Simulated database failure'); + }); $reported = []; $handler = Mockery::mock(ExceptionHandler::class); @@ -131,10 +133,10 @@ }); app()->instance(ExceptionHandler::class, $handler); - $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit); + $this->recorder->recordSubmission('arn:1', 'aws', $this->circuit, 500); $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['0' => 1]); expect($reported)->toHaveCount(2) - ->and($reported[0])->toBeInstanceOf(QueryException::class) - ->and($reported[1])->toBeInstanceOf(QueryException::class); + ->and($reported[0])->toBeInstanceOf(\RuntimeException::class) + ->and($reported[1])->toBeInstanceOf(\RuntimeException::class); }); From 9130531f8571e5cdb1d6a98198e53f7fa2f4d41a Mon Sep 17 00:00:00 2001 From: corgab Date: Fri, 11 Sep 2026 22:26:57 +0200 Subject: [PATCH 5/5] Fix task recorder completion clearing, error handling test, and style --- src/Jobs/SubmitQuantumCircuit.php | 2 +- src/Tasks/QuantumTaskRecorder.php | 2 ++ tests/Feature/PersistenceTest.php | 2 +- tests/Feature/Tasks/QuantumTaskRecorderTest.php | 8 +++----- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Jobs/SubmitQuantumCircuit.php b/src/Jobs/SubmitQuantumCircuit.php index 84f7c2d..9124be9 100644 --- a/src/Jobs/SubmitQuantumCircuit.php +++ b/src/Jobs/SubmitQuantumCircuit.php @@ -75,7 +75,7 @@ public function handle(QuantumManager $manager, ?QuantumTaskRecorder $recorder = // Best-effort by design: the remote task already exists at this point, // so the recorder reports and swallows a database failure rather than // letting the job retry and submit a second billable task. - $recorder->recordSubmission($taskArn, $driverName, $this->circuit, $this->circuit['shots'] ?? 0); + $recorder->recordSubmission($taskArn, $driverName, $this->circuit, $this->circuit['shots']); $this->schedulePolling($taskArn); } catch (\Throwable $e) { diff --git a/src/Tasks/QuantumTaskRecorder.php b/src/Tasks/QuantumTaskRecorder.php index 5f329c4..d56224d 100644 --- a/src/Tasks/QuantumTaskRecorder.php +++ b/src/Tasks/QuantumTaskRecorder.php @@ -61,6 +61,8 @@ public function recordProgress(string $taskArn, TaskStatus $status, ?array $coun if ($counts !== null) { $task->counts = $counts; $task->completed_at = now(); + $task->error = null; + $task->failed_at = null; } if ($error !== null) { diff --git a/tests/Feature/PersistenceTest.php b/tests/Feature/PersistenceTest.php index 1f254da..2884f97 100644 --- a/tests/Feature/PersistenceTest.php +++ b/tests/Feature/PersistenceTest.php @@ -82,7 +82,7 @@ it('still dispatches the poll job when the insert fails', function () { QuantumTask::saving(function () { - throw new \RuntimeException('Simulated database failure'); + throw new RuntimeException('Simulated database failure'); }); ($this->submit)(); diff --git a/tests/Feature/Tasks/QuantumTaskRecorderTest.php b/tests/Feature/Tasks/QuantumTaskRecorderTest.php index 6398ed9..5d6ee5d 100644 --- a/tests/Feature/Tasks/QuantumTaskRecorderTest.php +++ b/tests/Feature/Tasks/QuantumTaskRecorderTest.php @@ -122,9 +122,7 @@ // ------------------------------------------------------------------------- it('reports and swallows a database failure on either write', function () { - QuantumTask::saving(function () { - throw new \RuntimeException('Simulated database failure'); - }); + Schema::dropIfExists('quantum_tasks'); $reported = []; $handler = Mockery::mock(ExceptionHandler::class); @@ -137,6 +135,6 @@ $this->recorder->recordProgress('arn:1', TaskStatus::Completed, ['0' => 1]); expect($reported)->toHaveCount(2) - ->and($reported[0])->toBeInstanceOf(\RuntimeException::class) - ->and($reported[1])->toBeInstanceOf(\RuntimeException::class); + ->and($reported[0])->toBeInstanceOf(QueryException::class) + ->and($reported[1])->toBeInstanceOf(QueryException::class); });