From dfa8e5c7eb4a434be358d1e55086444e5bdb98ae Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:57:30 +0000 Subject: [PATCH 1/2] refactor(drivers): inject the cache store into LocalSimulatorDriver and read task_ttl from its own config LocalSimulatorDriver was the only driver reaching Laravel directly: the Cache facade for the dispatched results and the global config() helper for their retention. It now takes a cache repository through its constructor, next to the PythonExecutor, and reads the retention from its own config array as `task_ttl`, so the option moves from the top-level aether.local_task_ttl to aether.drivers.local.task_ttl (same env var). The unit test no longer needs a facade root or a bare container: an in-memory repository is injected, and a mocked one pins the TTL passed to put() for a configured, string, absent, zero and garbage value. Closes #59 --- README.md | 2 +- config/aether.php | 20 +++----- src/Drivers/LocalSimulatorDriver.php | 41 ++++++++++++--- src/QuantumManager.php | 2 + tests/Feature/AsynchronousExecutionTest.php | 3 +- tests/Feature/LifecycleEventsTest.php | 15 +++--- .../Unit/Drivers/LocalSimulatorDriverTest.php | 51 ++++++++++++------- 7 files changed, 86 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index a59f1ac..5f17d2d 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ AETHER_MAX_POLL_ATTEMPTS=720 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. -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. +The local simulator supports `->dispatch()` too — it executes immediately and caches the result under a synthetic `local:` task id for `drivers.local.task_ttl` seconds (`AETHER_LOCAL_TASK_TTL`, one hour by default), so you can develop the full asynchronous flow without touching AWS. #### Task Persistence diff --git a/config/aether.php b/config/aether.php index 4fff7db..b8badee 100644 --- a/config/aether.php +++ b/config/aether.php @@ -65,20 +65,6 @@ 'max_poll_attempts' => (int) env('AETHER_MAX_POLL_ATTEMPTS', 720), - /* - |-------------------------------------------------------------------------- - | Local Task Retention - |-------------------------------------------------------------------------- - | - | The local simulator has no real task queue, so asynchronously submitted - | circuits are executed immediately and their results are cached under a - | synthetic task identifier. This is how long, in seconds, those results - | stay available to the polling job. - | - */ - - 'local_task_ttl' => (int) env('AETHER_LOCAL_TASK_TTL', 3600), - /* |-------------------------------------------------------------------------- | Persist Asynchronous Tasks @@ -118,6 +104,12 @@ 'synchronous_safe' => true, 'entropy_qubits' => (int) env('AETHER_ENTROPY_QUBITS', 16), + // The local simulator has no real task queue: a dispatched circuit + // runs immediately and its result is cached under a synthetic task + // identifier. This is how long, in seconds, that result stays + // available to the polling job. + 'task_ttl' => (int) env('AETHER_LOCAL_TASK_TTL', 3600), + // The local simulator keeps a full statevector in memory: a dense // vector of 2^n complex128 amplitudes, 16 bytes each, so memory // use doubles with every additional qubit. The default of 25 diff --git a/src/Drivers/LocalSimulatorDriver.php b/src/Drivers/LocalSimulatorDriver.php index 010cf85..8b38ccf 100644 --- a/src/Drivers/LocalSimulatorDriver.php +++ b/src/Drivers/LocalSimulatorDriver.php @@ -6,10 +6,11 @@ use Aether\Circuit\CircuitBuilder; use Aether\Contracts\AsynchronousDevice; +use Aether\Contracts\PythonExecutor; use Aether\Exceptions\QuantumExecutionException; use Aether\Tasks\TaskSnapshot; use Aether\Tasks\TaskStatus; -use Illuminate\Support\Facades\Cache; +use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Str; /** @@ -23,8 +24,8 @@ * - submitCircuit() runs the circuit synchronously (via runCircuit(), so the * synchronous CircuitExecuted event does not fire for what is, to the * caller, an asynchronous dispatch), caches the resulting counts under a - * synthetic "local:" identifier, and returns that identifier as if - * it were a task ARN. + * synthetic "local:" identifier for `task_ttl` seconds, and returns + * that identifier as if it were a task ARN. * - checkTask() looks the identifier up in the cache and immediately * reports it as Completed (or Failed if the key is missing/expired). * @@ -37,6 +38,28 @@ class LocalSimulatorDriver extends AbstractQuantumDriver implements Asynchronous private const CACHE_PREFIX = 'aether:local-task:'; + /** + * Seconds a dispatched result stays available to the polling job when the + * driver config sets no `task_ttl`. + */ + public const DEFAULT_TASK_TTL = 3600; + + /** + * Like every driver, this one reads its options from the injected config + * array and reaches Laravel only through injected collaborators: the + * cache store the dispatched results live in is passed in here, so the + * driver needs neither a facade root nor the global config() helper. + * + * @param array $config + */ + public function __construct( + PythonExecutor $bridge, + array $config, + private readonly CacheRepository $cache, + ) { + parent::__construct($bridge, $config); + } + protected function driverName(): string { return 'local'; @@ -48,7 +71,7 @@ public function submitCircuit(CircuitBuilder $circuit): string $taskArn = self::ARN_PREFIX.(string) Str::uuid(); - Cache::put($this->cacheKey($taskArn), $result->counts(), $this->taskTtl()); + $this->cache->put($this->cacheKey($taskArn), $result->counts(), $this->taskTtl()); return $taskArn; } @@ -62,7 +85,7 @@ public function checkTask(string $taskArn): TaskSnapshot ); } - $counts = Cache::get($this->cacheKey($taskArn)); + $counts = $this->cache->get($this->cacheKey($taskArn)); if (! is_array($counts)) { return new TaskSnapshot(TaskStatus::Failed); @@ -77,8 +100,14 @@ private function cacheKey(string $taskArn): string return self::CACHE_PREFIX.$taskArn; } + /** + * Retention of a dispatched result, from the driver's own `task_ttl` + * option (`aether.drivers.local.task_ttl`). + */ private function taskTtl(): int { - return (int) config('aether.local_task_ttl', 3600); + $ttl = $this->config['task_ttl'] ?? null; + + return is_numeric($ttl) && (int) $ttl > 0 ? (int) $ttl : self::DEFAULT_TASK_TTL; } } diff --git a/src/QuantumManager.php b/src/QuantumManager.php index b288eaa..94cfdfc 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -16,6 +16,7 @@ use Aether\Testing\ResultSequence; use BackedEnum; use Closure; +use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Manager; use Illuminate\Support\Str; use UnitEnum; @@ -160,6 +161,7 @@ protected function createLocalDriver(): LocalSimulatorDriver return new LocalSimulatorDriver( $this->createBridge(), $this->config->get('aether.drivers.local', []), + $this->container->make(CacheRepository::class), ); } diff --git a/tests/Feature/AsynchronousExecutionTest.php b/tests/Feature/AsynchronousExecutionTest.php index 572da08..e6dbdea 100644 --- a/tests/Feature/AsynchronousExecutionTest.php +++ b/tests/Feature/AsynchronousExecutionTest.php @@ -11,6 +11,7 @@ use Aether\QuantumManager; use Aether\Results\CircuitResult; use Aether\Tasks\TaskStatus; +use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; @@ -45,7 +46,7 @@ $bridge = $this->createMock(PythonExecutor::class); $bridge->method('execute')->willReturn(['counts' => ['0' => 48, '1' => 52]]); - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); $circuit = Quantum::circuit('local')->qubits(1)->h(0)->measure()->shots(100); diff --git a/tests/Feature/LifecycleEventsTest.php b/tests/Feature/LifecycleEventsTest.php index 92e0c61..7a9e24d 100644 --- a/tests/Feature/LifecycleEventsTest.php +++ b/tests/Feature/LifecycleEventsTest.php @@ -9,6 +9,7 @@ use Aether\Exceptions\QuantumExecutionException; use Aether\Facades\Quantum; use Aether\Results\CircuitResult; +use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Facades\Event; // ------------------------------------------------------------------------- @@ -21,7 +22,7 @@ $bridge = $this->createMock(PythonExecutor::class); $bridge->method('execute')->willReturn(['counts' => ['0' => 48, '1' => 52]]); - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); $circuit = Quantum::circuit('local')->qubits(1)->h(0)->measure()->shots(100); @@ -43,7 +44,7 @@ $bridge = $this->createMock(PythonExecutor::class); $bridge->method('execute')->willReturn([]); // missing the "counts" key - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); expect(fn () => Quantum::circuit('local')->qubits(1)->h(0)->measure()->run()) @@ -58,7 +59,7 @@ $bridge = $this->createMock(PythonExecutor::class); $bridge->method('execute')->willReturn(['counts' => ['0' => 48, '1' => 52]]); - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); // The local driver simulates submission by running the circuit inline; to @@ -85,7 +86,7 @@ ); $bridge->method('execute')->willReturn(['bits' => str_repeat('1', 16)]); - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); Quantum::entropy('local')->generate(8); @@ -102,7 +103,7 @@ $bridge = $this->createMock(PythonExecutor::class); $bridge->method('execute')->willReturn([]); // missing the "bits" key - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); expect(fn () => Quantum::entropy('local')->generate(8)) @@ -160,7 +161,7 @@ ['counts' => ['1' => 100]], ]]); - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); $first = Quantum::circuit('local')->qubits(1)->h(0)->measure()->shots(100); @@ -181,7 +182,7 @@ $bridge = $this->createMock(PythonExecutor::class); $bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 100]]]]); - Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [])); + Quantum::extend('local', fn (): LocalSimulatorDriver => new LocalSimulatorDriver($bridge, [], app(CacheRepository::class))); Quantum::forgetDrivers(); $first = Quantum::circuit('local')->qubits(1)->h(0)->measure(); diff --git a/tests/Unit/Drivers/LocalSimulatorDriverTest.php b/tests/Unit/Drivers/LocalSimulatorDriverTest.php index 8dc0b81..8d8d0a1 100644 --- a/tests/Unit/Drivers/LocalSimulatorDriverTest.php +++ b/tests/Unit/Drivers/LocalSimulatorDriverTest.php @@ -15,9 +15,7 @@ use Aether\Tasks\TaskStatus; use Illuminate\Cache\ArrayStore; use Illuminate\Cache\Repository as CacheRepository; -use Illuminate\Config\Repository as ConfigRepository; -use Illuminate\Container\Container; -use Illuminate\Support\Facades\Cache; +use Illuminate\Contracts\Cache\Repository as CacheContract; use Illuminate\Support\Str; // ------------------------------------------------------------------------- @@ -38,20 +36,11 @@ return $bytes; }); $this->config = $config; - $this->driver = new LocalSimulatorDriver($this->bridge, $this->config); - - // submitCircuit()/checkTask() go through the Cache facade and the global - // config() helper. Neither requires a full Laravel app: an ArrayStore-backed - // cache repository is swapped into the facade, and a bare container carries - // a minimal config repository for the config() helper to resolve. - Cache::swap(new CacheRepository(new ArrayStore)); - Container::setInstance(tap(new Container, function (Container $container) { - $container->instance('config', new ConfigRepository(['aether' => ['local_task_ttl' => 3600]])); - })); -}); - -afterEach(function () { - Container::setInstance(null); + // The driver takes its cache store by injection, so an in-memory + // repository is all submitCircuit()/checkTask() need: no facade root, no + // container, no global config() helper. + $this->cache = new CacheRepository(new ArrayStore); + $this->driver = new LocalSimulatorDriver($this->bridge, $this->config, $this->cache); }); // ------------------------------------------------------------------------- @@ -193,6 +182,30 @@ $this->driver->submitCircuit($circuit); }); +it('caches a dispatched result for the configured task_ttl', function (mixed $ttl, int $expected) { + $this->bridge->method('execute')->willReturn(['counts' => ['0' => 100]]); + + $cache = $this->createMock(CacheContract::class); + $cache->expects($this->once()) + ->method('put') + ->with($this->stringStartsWith('aether:local-task:local:'), ['0' => 100], $expected) + ->willReturn(true); + + $driver = new LocalSimulatorDriver($this->bridge, array_merge($this->config, ['task_ttl' => $ttl]), $cache); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 100]); + $circuit->method('qubitCount')->willReturn(1); + + $driver->submitCircuit($circuit); +})->with([ + 'configured' => [42, 42], + 'numeric string from env' => ['90', 90], + 'absent' => [null, LocalSimulatorDriver::DEFAULT_TASK_TTL], + 'zero' => [0, LocalSimulatorDriver::DEFAULT_TASK_TTL], + 'garbage' => ['soon', LocalSimulatorDriver::DEFAULT_TASK_TTL], +]); + it('checkTask reports Failed for an unknown task key', function () { $snapshot = $this->driver->checkTask('local:'.Str::uuid()); @@ -211,7 +224,7 @@ // ------------------------------------------------------------------------- it('rejects submitCircuit when the circuit exceeds max_qubits', function () use ($config) { - $driver = new LocalSimulatorDriver($this->bridge, array_merge($config, ['max_qubits' => 5])); + $driver = new LocalSimulatorDriver($this->bridge, array_merge($config, ['max_qubits' => 5]), $this->cache); $circuit = $this->createMock(CircuitBuilder::class); $circuit->method('qubitCount')->willReturn(6); @@ -224,7 +237,7 @@ }); it('allows submitCircuit when the circuit is within max_qubits', function () use ($config) { - $driver = new LocalSimulatorDriver($this->bridge, array_merge($config, ['max_qubits' => 5])); + $driver = new LocalSimulatorDriver($this->bridge, array_merge($config, ['max_qubits' => 5]), $this->cache); $circuit = $this->createMock(CircuitBuilder::class); $circuit->method('qubitCount')->willReturn(5); From 71bae9f01dbbac8e3f127af4e5df374d9f5a3b32 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 19:01:33 +0000 Subject: [PATCH 2/2] fix(drivers): honour the legacy local_task_ttl key and share the TTL default createLocalDriver() copies a top-level aether.local_task_ttl into the driver config when drivers.local.task_ttl is absent, so a config file published before the key moved keeps its retention. The shipped default now points at LocalSimulatorDriver::DEFAULT_TASK_TTL, and the constructor docblock notes that the injected store is fixed for the driver's lifetime. --- config/aether.php | 3 ++- src/Drivers/LocalSimulatorDriver.php | 5 +++++ src/QuantumManager.php | 13 ++++++++++++- tests/Feature/ServiceProviderTest.php | 28 +++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/config/aether.php b/config/aether.php index b8badee..477ae5d 100644 --- a/config/aether.php +++ b/config/aether.php @@ -1,6 +1,7 @@ (int) env('AETHER_LOCAL_TASK_TTL', 3600), + 'task_ttl' => (int) env('AETHER_LOCAL_TASK_TTL', LocalSimulatorDriver::DEFAULT_TASK_TTL), // The local simulator keeps a full statevector in memory: a dense // vector of 2^n complex128 amplitudes, 16 bytes each, so memory diff --git a/src/Drivers/LocalSimulatorDriver.php b/src/Drivers/LocalSimulatorDriver.php index 8b38ccf..07fe460 100644 --- a/src/Drivers/LocalSimulatorDriver.php +++ b/src/Drivers/LocalSimulatorDriver.php @@ -50,6 +50,11 @@ class LocalSimulatorDriver extends AbstractQuantumDriver implements Asynchronous * cache store the dispatched results live in is passed in here, so the * driver needs neither a facade root nor the global config() helper. * + * The store is fixed for the driver's lifetime (QuantumManager resolves + * the application's default store when it builds the driver), so a + * default-store switch made afterwards is picked up only once the driver + * is rebuilt, e.g. after Quantum::forgetDrivers(). + * * @param array $config */ public function __construct( diff --git a/src/QuantumManager.php b/src/QuantumManager.php index 94cfdfc..192de64 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -158,9 +158,20 @@ protected function createDriver($driver) */ protected function createLocalDriver(): LocalSimulatorDriver { + $config = $this->config->get('aether.drivers.local', []); + $config = is_array($config) ? $config : []; + + // The retention used to be the top-level `aether.local_task_ttl`. A + // config file published before it moved under drivers.local still + // carries that key and nothing else, so honour it until the app + // republishes; an explicit `task_ttl` always wins. + if (! array_key_exists('task_ttl', $config) && $this->config->has('aether.local_task_ttl')) { + $config['task_ttl'] = $this->config->get('aether.local_task_ttl'); + } + return new LocalSimulatorDriver( $this->createBridge(), - $this->config->get('aether.drivers.local', []), + $config, $this->container->make(CacheRepository::class), ); } diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php index a462f4e..8408aaf 100644 --- a/tests/Feature/ServiceProviderTest.php +++ b/tests/Feature/ServiceProviderTest.php @@ -125,6 +125,34 @@ expect($this->app['config']->get('aether.drivers.aws.max_cost_per_run'))->toBeNull(); }); +// ------------------------------------------------------------------------- +// local task_ttl +// ------------------------------------------------------------------------- + +it('defaults the local driver task_ttl to one hour', function () { + expect($this->app['config']->get('aether.drivers.local.task_ttl'))->toBe(3600); +}); + +it('honours a legacy top-level local_task_ttl from a config file published before the key moved', function () { + config()->set('aether.drivers.local', ['synchronous_safe' => true]); + config()->set('aether.local_task_ttl', 60); + + $driver = (new QuantumManager($this->app))->driver('local'); + $config = (new ReflectionProperty($driver, 'config'))->getValue($driver); + + expect($config['task_ttl'])->toBe(60); +}); + +it('prefers an explicit drivers.local.task_ttl over the legacy key', function () { + config()->set('aether.drivers.local.task_ttl', 120); + config()->set('aether.local_task_ttl', 60); + + $driver = (new QuantumManager($this->app))->driver('local'); + $config = (new ReflectionProperty($driver, 'config'))->getValue($driver); + + expect($config['task_ttl'])->toBe(120); +}); + // ------------------------------------------------------------------------- // php artisan about // -------------------------------------------------------------------------