Skip to content
Merged
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 @@ -55,6 +55,7 @@ Quantum (Facade)
- **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo.
- **No double submission:** SubmitQuantumCircuit retries only before submitCircuit() returns; a post-submission failure fails the job (or rethrows when not under a worker) instead of letting a retryable exception escape, so a queued retry can never create a second billable task.
- **Ceilings cover entropy:** generateEntropy() describes its circuit as a CircuitBuilder and runs validateCircuits(), so max_qubits and max_cost_per_run apply to Quantum::entropy() too.
- **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

Expand Down
60 changes: 12 additions & 48 deletions src/Jobs/PollQuantumTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,8 +68,10 @@ public function tries(): int
/**
* Execute the job.
*/
public function handle(QuantumManager $manager, Dispatcher $events): void
public function handle(QuantumManager $manager, Dispatcher $events, ?QuantumTaskRecorder $recorder = null): void
{
$recorder ??= app(QuantumTaskRecorder::class);

$driverName = $this->driver ?? config('aether.default', 'local');
$device = $manager->driver($this->driver);

Expand All @@ -80,24 +81,27 @@ 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.
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;
}

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;
}

Expand All @@ -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,
Expand All @@ -119,44 +123,4 @@ public function handle(QuantumManager $manager, Dispatcher $events): void
$this->taskArn,
));
}

/**
* Mirror the backend state onto the persisted quantum_tasks row when enabled.
*
* @param array<string, int>|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();
// A completed task is not failed, whatever an earlier polling
// hiccup (e.g. a scheduling failure recovered by hand) recorded.
$task->error = null;
$task->failed_at = null;
}

if ($error !== null) {
$task->error = $error;
$task->failed_at = now();
}

$task->save();
} catch (\Throwable $e) {
report($e);
}
}
}
70 changes: 9 additions & 61 deletions src/Jobs/SubmitQuantumCircuit.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
use Aether\Contracts\AsynchronousDevice;
use Aether\Contracts\QuantumDevice;
use Aether\Exceptions\QuantumExecutionException;
use Aether\Models\QuantumTask;
use Aether\QuantumManager;
use Aether\Tasks\QuantumTaskRecorder;
use Aether\Tasks\TaskStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
Expand Down Expand Up @@ -56,8 +56,10 @@ public function __construct(
/**
* Execute the job.
*/
public function handle(QuantumManager $manager): void
public function handle(QuantumManager $manager, ?QuantumTaskRecorder $recorder = null): void
{
$recorder ??= app(QuantumTaskRecorder::class);

$driverName = $this->driver ?? config('aether.default', 'local');
$device = $manager->driver($this->driver);

Expand All @@ -70,13 +72,16 @@ public function handle(QuantumManager $manager): void
$taskArn = $device->submitCircuit($builder);

try {
$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.
$recorder->recordSubmission($taskArn, $driverName, $this->circuit, $this->circuit['shots']);

$this->schedulePolling($taskArn);
} catch (\Throwable $e) {
$exception = QuantumExecutionException::pollingNotScheduled($taskArn, $driverName, $e);

$this->persistSchedulingFailure($taskArn, $exception->getMessage());
$recorder->recordProgress($taskArn, TaskStatus::Created, null, $exception->getMessage());

// Outside a real worker there is nothing to mark as failed: with no
// queue job, or on the sync connection (where the poll job has just
Expand Down Expand Up @@ -106,61 +111,4 @@ private function schedulePolling(string $taskArn): void
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);
}
}

/**
* Best-effort record of a post-submission scheduling failure on the
* persisted quantum_tasks row, when persistence is enabled.
*
* A database failure here is reported and swallowed rather than allowed
* to escape: the job is already being failed for the scheduling error
* itself, and this bookkeeping must never mask or replace that.
*/
private function persistSchedulingFailure(string $taskArn, string $message): void
{
if (! config('aether.persist_tasks', false)) {
return;
}

try {
$task = QuantumTask::query()->where('task_arn', $taskArn)->first();

if ($task === null) {
return;
}

$task->error = $message;
$task->failed_at = now();
$task->save();
} catch (\Throwable $e) {
report($e);
}
}
}
103 changes: 103 additions & 0 deletions src/Tasks/QuantumTaskRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Aether\Tasks;

use Aether\Models\QuantumTask;

/**
* Mirrors the lifecycle of an asynchronous task into the quantum_tasks table.
*
* Both queue jobs record through this class so the two rules of persistence
* live in one place: nothing is written unless `aether.persist_tasks` is on,
* and a database failure is reported and swallowed. The remote task already
* exists (or is already being polled) by the time either method runs, so a
* failed write must never fail the job, retry a billable submission, or
* suppress the CircuitCompleted event.
*/
class QuantumTaskRecorder
{
/**
* Record a freshly submitted task.
*
* @param array{qubits: int, gates: array<int, array<string, mixed>>, shots: int} $circuit The CircuitBuilder::toArray() payload that was submitted.
*/
public function recordSubmission(string $taskArn, string $driver, array $circuit, int $shots): 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' => $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<string, int>|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();
$task->error = null;
$task->failed_at = null;
}

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);
}
}
}
4 changes: 3 additions & 1 deletion tests/Feature/AsynchronousExecutionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\Cache\Repository as CacheRepository;
use Illuminate\Contracts\Events\Dispatcher;
Expand Down Expand Up @@ -52,7 +53,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;

Expand All @@ -66,6 +67,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 {
Expand Down
Loading