diff --git a/README.md b/README.md index a59f1ac..0c746ac 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index 9a4c0b2..17c8417 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -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|null $counts */ @@ -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) { diff --git a/tests/Feature/PersistenceTest.php b/tests/Feature/PersistenceTest.php index 34e4bc4..3532712 100644 --- a/tests/Feature/PersistenceTest.php +++ b/tests/Feature/PersistenceTest.php @@ -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);