diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..ce6aaa6 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. +- **Both outcomes have an event:** `PollQuantumTask` dispatches `CircuitCompleted` on success and `CircuitFailed` (driver, circuit, task ARN, last status, reason) right before throwing on failure. Both belong to the job: `QuantumFake` only reports statuses, so a job run against it dispatches each event once. ## Config diff --git a/README.md b/README.md index a59f1ac..9853174 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ AETHER_MAX_POLL_ATTEMPTS=720 `PollQuantumTask` re-checks the task with Laravel's job `release()`, waiting `AETHER_POLL_INTERVAL` seconds between attempts, so asynchronous AWS execution needs a real queue connection with a running worker (`php artisan queue:work`). The `sync` connection is not supported: there `release()` is a no-op, so polling stops silently after the first non-terminal check — no event, no error. The local driver is unaffected, since its tasks are already terminal on the first poll. -A task that fails or is cancelled throws `TaskFailedException` from the polling job; one that never finishes within `max_poll_attempts` throws `QuantumExecutionException`. Both land in `failed_jobs` with the task ARN in the message, so you can inspect the task in the AWS console. The job declares `$maxExceptions = 1`, so any exception fails it immediately without retries — the re-check loop is driven by `release()`, not by queue retries. +A task that fails or is cancelled throws `TaskFailedException` from the polling job; one that never finishes within `max_poll_attempts` throws `QuantumExecutionException`. Both land in `failed_jobs` with the task ARN in the message, so you can inspect the task in the AWS console. Right before throwing, the job dispatches `Aether\Events\CircuitFailed` with the driver, the circuit, the task ARN, the last status the backend reported and the reason, so application code can react to the failure (notify a user, refund a credit, resubmit elsewhere) with a listener instead of reading `failed_jobs`. The job declares `$maxExceptions = 1`, so any exception fails it immediately without retries — the re-check loop is driven by `release()`, not by queue retries. The local simulator supports `->dispatch()` too — it executes immediately and caches the result under a synthetic `local:` task id, so you can develop the full asynchronous flow without touching AWS. @@ -344,13 +344,15 @@ Aether dispatches events at each execution choke point, so application code can | `CircuitExecuted` | A circuit finishes executing synchronously (`->run()`, or once per circuit of a `Quantum::batch()->run()`) | `driver` (`string`), `circuit` (the `toArray()` definition), `result` (`CircuitResult`) | | `EntropyGenerated` | A device generates entropy (`EntropyGenerator::generate()`/`hex()`/`integer()`) | `driver` (`string`), `bits` (`int`, the requested bit count) | | `CircuitCompleted` | An asynchronously dispatched task (`->dispatch()`) reaches a terminal state | `driver` (`string`), `circuit`, `result` (`CircuitResult`), `taskArn` (`?string`) — see [Asynchronous Execution](#asynchronous-execution) | +| `CircuitFailed` | An asynchronously dispatched task ends without a result: the backend reports `FAILED`/`CANCELLED`, the polling budget is exhausted, or the task completes without counts | `driver` (`string`), `circuit`, `taskArn` (`string`), `status` (`TaskStatus`, the last status read), `reason` (`string`, the exception message) | `EntropyGenerated` deliberately never carries the generated bytes: entropy typically feeds tokens, keys or nonces, so exposing the value to every registered listener would defeat the point of keeping it secret. Capture `EntropyGenerator::generate()`/`hex()`/`integer()`'s return value directly if you need the material itself. -None of these events fire when execution fails — a malformed response or a driver exception is raised before the event is dispatched. +`CircuitExecuted` and `EntropyGenerated` never fire when synchronous execution fails — a malformed response or a driver exception is raised before the event is dispatched. Asynchronous failures are announced by `CircuitFailed`, which is dispatched right before the polling job throws, so both outcomes of a `->dispatch()` have an event. ```php use Aether\Events\CircuitExecuted; +use Aether\Events\CircuitFailed; use Aether\Events\EntropyGenerated; use Illuminate\Support\Facades\Event; @@ -363,9 +365,15 @@ Event::listen(function (EntropyGenerated $event) { $event->bits; // 256 $event->driver; // 'aws' }); + +Event::listen(function (CircuitFailed $event) { + $event->taskArn; // 'arn:aws:braket:...' + $event->status->value; // 'FAILED', 'CANCELLED', or the last non-terminal status + $event->reason; // 'Quantum task [...] terminated with status [FAILED].' +}); ``` -`Quantum::fake()` dispatches `CircuitExecuted` and `EntropyGenerated` too, mirroring the real drivers, so `Event::fake()` assertions on application code keep working the same way whether or not the backend itself is faked. `CircuitExecuted` fires only for synchronous execution (`->run()` and `Quantum::batch()->run()`): a local `->dispatch()` runs the simulator inline but announces itself through `CircuitCompleted` alone, like the `aws` driver. +`Quantum::fake()` dispatches `CircuitExecuted` and `EntropyGenerated` too, mirroring the real drivers, so `Event::fake()` assertions on application code keep working the same way whether or not the backend itself is faked. `CircuitExecuted` fires only for synchronous execution (`->run()` and `Quantum::batch()->run()`): a local `->dispatch()` runs the simulator inline but announces itself through `CircuitCompleted` alone, like the `aws` driver. `CircuitCompleted` and `CircuitFailed` belong to the polling job, not to the device, so the fake does not dispatch them itself: a job run against `Quantum::fake()->respondWithTaskStatus(TaskStatus::Failed)` produces exactly one `CircuitFailed`. ## Testing diff --git a/src/Events/CircuitFailed.php b/src/Events/CircuitFailed.php new file mode 100644 index 0000000..053cf6c --- /dev/null +++ b/src/Events/CircuitFailed.php @@ -0,0 +1,33 @@ +>, shots: int} $circuit + * @param TaskStatus $status The last status the backend reported: FAILED or CANCELLED, + * the non-terminal status seen when the polling budget ran out, + * or COMPLETED for a task that returned no counts. + * @param string $reason The message of the exception the polling job throws. + */ + public function __construct( + public string $driver, + public array|string $circuit, + public string $taskArn, + public TaskStatus $status, + public string $reason, + ) {} +} diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index 9a4c0b2..255e766 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -7,6 +7,8 @@ use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\QuantumDevice; use Aether\Events\CircuitCompleted; +use Aether\Events\CircuitFailed; +use Aether\Exceptions\AetherException; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; use Aether\Models\QuantumTask; @@ -16,14 +18,17 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Throwable; /** * Polls an asynchronous quantum task until it reaches a terminal state. * * Uses Laravel's native job release mechanism to check the task status * repeatedly up to `aether.max_poll_attempts`. Once the task completes, - * fires {@see CircuitCompleted}; a non-successful terminal state raises - * {@see TaskFailedException}. + * fires {@see CircuitCompleted}. A task that ends without a result (a + * non-successful terminal state, an exhausted polling budget, or a completed + * task with no counts) fires {@see CircuitFailed} and then raises + * {@see TaskFailedException} or {@see QuantumExecutionException}. * * The high attempt allowance exists purely to budget the polling loop, so * genuine failures are capped separately by {@see $maxExceptions}. @@ -84,9 +89,12 @@ public function handle(QuantumManager $manager, Dispatcher $events): void $maxAttempts = $this->tries(); if ($this->attempts() >= $maxAttempts) { - $e = QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts()); - $this->persist($snapshot->status, null, $e->getMessage()); - throw $e; + $this->abandonTask( + $events, + $driverName, + $snapshot->status, + QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts()), + ); } $this->persist($snapshot->status); @@ -96,18 +104,24 @@ 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()); - throw $e; + $this->abandonTask( + $events, + $driverName, + $snapshot->status, + TaskFailedException::forTask($this->taskArn, $snapshot->status), + ); } if ($snapshot->counts === null) { - $e = QuantumExecutionException::malformedResponse( - 'checkTask', - "task [{$this->taskArn}] completed but returned no measurement counts." + $this->abandonTask( + $events, + $driverName, + $snapshot->status, + QuantumExecutionException::malformedResponse( + 'checkTask', + "task [{$this->taskArn}] completed but returned no measurement counts." + ), ); - $this->persist($snapshot->status, null, $e->getMessage()); - throw $e; } $this->persist($snapshot->status, $snapshot->counts); @@ -120,6 +134,35 @@ public function handle(QuantumManager $manager, Dispatcher $events): void )); } + /** + * Record a task that ended without a result, announce it, and fail the job. + * + * CircuitFailed is the counterpart of CircuitCompleted: it is dispatched + * before the exception so application code can react to the failure + * (notify, refund, retry elsewhere) without reading failed_jobs. The + * exception still propagates so the job is failed and recorded as usual; + * a listener that throws is reported and swallowed, so it can never + * replace the task failure as the reason the job failed. + */ + private function abandonTask(Dispatcher $events, string $driverName, TaskStatus $status, \Throwable $exception): never + { + $this->persist($status, null, $exception->getMessage()); + + try { + $events->dispatch(new CircuitFailed( + $driverName, + $this->circuit, + $this->taskArn, + $status, + $exception->getMessage(), + )); + } catch (Throwable $listenerFailure) { + report($listenerFailure); + } + + throw $exception; + } + /** * Mirror the backend state onto the persisted quantum_tasks row, when * persistence is enabled. @@ -158,7 +201,7 @@ private function persist(TaskStatus $status, ?array $counts = null, ?string $err } $task->save(); - } catch (\Throwable $e) { + } catch (Throwable $e) { report($e); } } diff --git a/src/Testing/QuantumFake.php b/src/Testing/QuantumFake.php index cea1827..6fe6293 100644 --- a/src/Testing/QuantumFake.php +++ b/src/Testing/QuantumFake.php @@ -326,6 +326,9 @@ public function respondEntropyWith(string|Closure $entropy): static * flight (e.g. Queued, Running) or that terminated unsuccessfully * (Failed, Cancelled), so polling loops and event handling can be * exercised in tests. + * + * Like a real backend, the fake only reports the status: CircuitCompleted + * and CircuitFailed are dispatched by the polling job that reads it. */ public function respondWithTaskStatus(TaskStatus $status): static { diff --git a/tests/Feature/CircuitFailedEventTest.php b/tests/Feature/CircuitFailedEventTest.php new file mode 100644 index 0000000..6f22984 --- /dev/null +++ b/tests/Feature/CircuitFailedEventTest.php @@ -0,0 +1,63 @@ +respondWithTaskStatus($status); + $circuit = (new CircuitBuilder($fake, 'aws'))->qubits(1)->h(0)->measure(); + $arn = $fake->submitCircuit($circuit); + + $job = new PollQuantumTask($arn, $circuit->toArray(), 'aws'); + + expect(fn () => $job->handle(app(QuantumManager::class), app(Dispatcher::class))) + ->toThrow(TaskFailedException::class); + + Event::assertDispatchedTimes(CircuitFailed::class, 1); + Event::assertDispatched( + CircuitFailed::class, + fn (CircuitFailed $event): bool => $event->driver === 'aws' + && $event->taskArn === $arn + && $event->status === $status + && $event->circuit === $circuit->toArray(), + ); + Event::assertNotDispatched(CircuitCompleted::class); +})->with([TaskStatus::Failed, TaskStatus::Cancelled]); + +it('does not make the fake itself dispatch CircuitFailed when polled', function () { + Event::fake(); + + $fake = Quantum::fake()->respondWithTaskStatus(TaskStatus::Failed); + $arn = $fake->submitCircuit((new CircuitBuilder($fake))->qubits(1)->measure()); + + $fake->checkTask($arn); + + Event::assertNotDispatched(CircuitFailed::class); +}); + +it('still fails the job with the task exception when a CircuitFailed listener throws', function () { + $fake = Quantum::fake()->respondWithTaskStatus(TaskStatus::Failed); + $circuit = (new CircuitBuilder($fake, 'aws'))->qubits(1)->h(0)->measure(); + $arn = $fake->submitCircuit($circuit); + + Event::listen(CircuitFailed::class, function (): void { + throw new RuntimeException('notification provider is down'); + }); + + $job = new PollQuantumTask($arn, $circuit->toArray(), 'aws'); + + expect(fn () => $job->handle(app(QuantumManager::class), app(Dispatcher::class))) + ->toThrow(TaskFailedException::class, $arn); +}); diff --git a/tests/Feature/Jobs/PollQuantumTaskTest.php b/tests/Feature/Jobs/PollQuantumTaskTest.php index e9222c2..9aebf7c 100644 --- a/tests/Feature/Jobs/PollQuantumTaskTest.php +++ b/tests/Feature/Jobs/PollQuantumTaskTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Aether\Events\CircuitCompleted; +use Aether\Events\CircuitFailed; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; use Aether\Jobs\PollQuantumTask; @@ -81,6 +82,93 @@ $job->handle($manager, app(Dispatcher::class)); })->with([TaskStatus::Failed, TaskStatus::Cancelled])->throws(TaskFailedException::class); +it('dispatches CircuitFailed before throwing when the task terminates as failed or cancelled', function (TaskStatus $status) { + Event::fake(); + + $device = new FakeAsynchronousDevice; + $device->snapshotToReturn = new TaskSnapshot($status); + + $manager = app(QuantumManager::class); + $manager->extend('fake-async', fn () => $device); + + $circuit = ['qubits' => 2, 'gates' => [], 'shots' => 100]; + $job = new PollQuantumTask($device->taskArnToReturn, $circuit, 'fake-async'); + + expect(fn () => $job->handle($manager, app(Dispatcher::class)))->toThrow(TaskFailedException::class); + + Event::assertDispatched( + CircuitFailed::class, + fn (CircuitFailed $event): bool => $event->driver === 'fake-async' + && $event->taskArn === $device->taskArnToReturn + && $event->circuit === $circuit + && $event->status === $status + && str_contains($event->reason, $status->value), + ); + Event::assertNotDispatched(CircuitCompleted::class); +})->with([TaskStatus::Failed, TaskStatus::Cancelled]); + +it('dispatches CircuitFailed with the last known status when the polling budget is exhausted', function () { + Event::fake(); + config(['aether.max_poll_attempts' => 2]); + + $device = new FakeAsynchronousDevice; + $device->snapshotToReturn = new TaskSnapshot(TaskStatus::Running); + + $manager = app(QuantumManager::class); + $manager->extend('fake-async', fn () => $device); + + $mockJob = Mockery::mock(Job::class); + $mockJob->shouldReceive('attempts')->andReturn(2); + + $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); + $job->setJob($mockJob); + + expect(fn () => $job->handle($manager, app(Dispatcher::class)))->toThrow(QuantumExecutionException::class); + + Event::assertDispatched( + CircuitFailed::class, + fn (CircuitFailed $event): bool => $event->status === TaskStatus::Running + && $event->taskArn === $device->taskArnToReturn + && str_contains($event->reason, $device->taskArnToReturn), + ); +}); + +it('dispatches CircuitFailed when the task completes without counts', function () { + Event::fake(); + + $device = new FakeAsynchronousDevice; + $device->snapshotToReturn = new TaskSnapshot(TaskStatus::Completed, null); + + $manager = app(QuantumManager::class); + $manager->extend('fake-async', fn () => $device); + + $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); + + expect(fn () => $job->handle($manager, app(Dispatcher::class)))->toThrow(QuantumExecutionException::class); + + Event::assertDispatched( + CircuitFailed::class, + fn (CircuitFailed $event): bool => $event->status === TaskStatus::Completed + && str_contains($event->reason, 'no measurement counts'), + ); + Event::assertNotDispatched(CircuitCompleted::class); +}); + +it('does not dispatch CircuitFailed when the task completes with counts', function () { + Event::fake(); + + $device = new FakeAsynchronousDevice; + $device->snapshotToReturn = new TaskSnapshot(TaskStatus::Completed, ['0' => 1]); + + $manager = app(QuantumManager::class); + $manager->extend('fake-async', fn () => $device); + + $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 1, 'gates' => [], 'shots' => 1], 'fake-async'); + $job->handle($manager, app(Dispatcher::class)); + + Event::assertNotDispatched(CircuitFailed::class); +}); + it('dispatches CircuitCompleted with the counts and task arn once completed', function () { Event::fake();