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

Expand Down
21 changes: 7 additions & 14 deletions config/aether.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

declare(strict_types=1);
use Aether\Drivers\LocalSimulatorDriver;

return [

Expand Down Expand Up @@ -65,20 +66,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
Expand Down Expand Up @@ -118,6 +105,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', 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
// use doubles with every additional qubit. The default of 25
Expand Down
46 changes: 40 additions & 6 deletions src/Drivers/LocalSimulatorDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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:<uuid>" identifier, and returns that identifier as if
* it were a task ARN.
* synthetic "local:<uuid>" 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).
*
Expand All @@ -37,6 +38,33 @@ 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.
*
* 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<string, mixed> $config
*/
public function __construct(
PythonExecutor $bridge,
array $config,
private readonly CacheRepository $cache,
) {
parent::__construct($bridge, $config);
}

protected function driverName(): string
{
return 'local';
Expand All @@ -48,7 +76,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;
}
Expand All @@ -62,7 +90,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);
Expand All @@ -77,8 +105,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;
}
}
15 changes: 14 additions & 1 deletion src/QuantumManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -157,9 +158,21 @@ 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),
);
}

Expand Down
3 changes: 2 additions & 1 deletion tests/Feature/AsynchronousExecutionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 8 additions & 7 deletions tests/Feature/LifecycleEventsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// -------------------------------------------------------------------------
Expand All @@ -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);
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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))
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions tests/Feature/ServiceProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
51 changes: 32 additions & 19 deletions tests/Unit/Drivers/LocalSimulatorDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// -------------------------------------------------------------------------
Expand All @@ -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);
});

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

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