Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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;

Expand All @@ -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

Expand Down
33 changes: 33 additions & 0 deletions src/Events/CircuitFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Aether\Events;

use Aether\Tasks\TaskStatus;

/**
* Fired when an asynchronously dispatched quantum circuit ends without a result.
*
* The counterpart of CircuitCompleted: the backend reported FAILED or
* CANCELLED, the polling budget ran out, or the task completed without
* measurement counts. The polling job dispatches it right before throwing,
* so listeners can react without reading the failed_jobs table.
*/
final readonly class CircuitFailed
{
/**
* @param array{qubits: int, gates: array<int, array<string, mixed>>, 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 $circuit,
public string $taskArn,
public TaskStatus $status,
public string $reason,
) {}
}
71 changes: 57 additions & 14 deletions src/Jobs/PollQuantumTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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, AetherException $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.
Expand Down Expand Up @@ -158,7 +201,7 @@ private function persist(TaskStatus $status, ?array $counts = null, ?string $err
}

$task->save();
} catch (\Throwable $e) {
} catch (Throwable $e) {
report($e);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/Testing/QuantumFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
63 changes: 63 additions & 0 deletions tests/Feature/CircuitFailedEventTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

use Aether\Circuit\CircuitBuilder;
use Aether\Events\CircuitCompleted;
use Aether\Events\CircuitFailed;
use Aether\Exceptions\TaskFailedException;
use Aether\Facades\Quantum;
use Aether\Jobs\PollQuantumTask;
use Aether\QuantumManager;
use Aether\Tasks\TaskStatus;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\Event;

it('dispatches CircuitFailed exactly once when the polling job runs against a stubbed failure', function (TaskStatus $status) {
Event::fake();

$fake = Quantum::fake()->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);
});
88 changes: 88 additions & 0 deletions tests/Feature/Jobs/PollQuantumTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down