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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ php artisan migrate

Set `AETHER_PERSIST_TASKS=true` in your `.env`.

When enabled, Aether inserts a row into `quantum_tasks` containing the circuit, shots, and driver when a task is dispatched, and updates its `status` and `counts` as the polling job progresses. The `status` always mirrors the backend's real state. Polling problems (like exhaustion or malformed responses) are logged in `error` and `failed_at`.
When enabled, Aether inserts a row into `quantum_tasks` containing the circuit, shots, and driver when a task is dispatched, and updates its `status` and `counts` as the polling job progresses. The `status` always mirrors the backend's real state. Polling problems (like exhaustion or malformed responses) are logged in `error` and `failed_at`. While a task is queued or running, each poll issues a single conditional update that writes nothing when the status is unchanged, so an hour of polling a queued QPU task costs one query per poll and no row churn. Those intermediate updates go straight to the query builder, so `QuantumTask` model observers only see the terminal transition (completed, failed, cancelled, or a polling error); listen to `CircuitCompleted` or query the table for anything earlier.

Since persistence is strictly best-effort, a database failure never affects queue behaviour or prevents the `CircuitCompleted` event from being emitted.

Expand Down
23 changes: 20 additions & 3 deletions src/Jobs/PollQuantumTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ public function handle(QuantumManager $manager, Dispatcher $events): void
*
* 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.
* populate error and failed_at. A status-only update, the common case
* while a task is queued or running, is a single conditional query that
* touches no row when the status has not changed and does not fire the
* QuantumTask model events; the terminal transitions do. 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<string, int>|null $counts
*/
Expand All @@ -139,6 +142,20 @@ private function persist(TaskStatus $status, ?array $counts = null, ?string $err
}

try {
if ($counts === null && $error === null) {
// An intermediate poll only mirrors the status: one conditional
// UPDATE instead of a SELECT per poll, and no row is written
// (nor updated_at bumped) while the backend reports the same
// status as before. This bypasses the model's events; the
// terminal transitions below still go through save().
QuantumTask::query()
->where('task_arn', $this->taskArn)
->where('status', '!=', $status->value)
->update(['status' => $status->value]);

return;
}

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

if ($task === null) {
Expand Down
34 changes: 34 additions & 0 deletions tests/Feature/PersistenceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,40 @@
expect(QuantumTask::query()->firstOrFail()->status)->toBe(TaskStatus::Running);
});

it('issues a single conditional update and writes nothing when the status is unchanged', function () {
$this->device->snapshotToReturn = new TaskSnapshot(TaskStatus::Running);
$job = ($this->submit)()->withFakeQueueInteractions();

($this->poll)($job);
$before = QuantumTask::query()->firstOrFail();
$this->travel(5)->seconds();

DB::enableQueryLog();
($this->poll)($job);
$queries = DB::getQueryLog();
DB::disableQueryLog();

expect($queries)->toHaveCount(1)
->and(strtolower($queries[0]['query']))->toStartWith('update')
->and(QuantumTask::query()->firstOrFail()->updated_at->equalTo($before->updated_at))->toBeTrue();
});

it('records a changed intermediate status without reading the row first', function () {
$this->device->snapshotToReturn = new TaskSnapshot(TaskStatus::Queued);
$job = ($this->submit)()->withFakeQueueInteractions();
($this->poll)($job);

$this->device->snapshotToReturn = new TaskSnapshot(TaskStatus::Running);

DB::enableQueryLog();
($this->poll)($job);
$queries = DB::getQueryLog();
DB::disableQueryLog();

expect($queries)->toHaveCount(1)
->and(QuantumTask::query()->firstOrFail()->status)->toBe(TaskStatus::Running);
});

it('keeps the backend status and records the error when polling is exhausted', function () {
config()->set('aether.max_poll_attempts', 1);
$this->device->snapshotToReturn = new TaskSnapshot(TaskStatus::Running);
Expand Down