Context
Surfaced while writing the persistence e2e suite for #73 (commit 93d9a02, the concurrent register collision scenario). The suite stubs AhandHubClient, so the production behaviour described below is not directly observed in tests — but the code path is reachable.
The race
AhandDevicesService.registerDeviceForUser catches Postgres UNIQUE violations (code === '23505') and compensates by calling hub.deleteDevice(input.hubDeviceId):
```ts
} catch (e) {
if ((e as { code?: string }).code === '23505') {
// Compensate hub registration since the device is already in DB
await this.hub.deleteDevice(input.hubDeviceId).catch(...);
throw new ConflictException('Device already registered');
}
...
}
```
The compensation key is the bare `hubDeviceId` — there's no per-(deviceId, externalUserId) targeting. Whether this is buggy depends on how ahand-hub keys its admin storage:
| Hub keying |
Behaviour |
Bug? |
| Globally unique on `deviceId` |
Loser's step 1 (`hub.registerDevice`) returns 409 → catch never reached |
No |
| Per-(deviceId, externalUserId) |
Two users can both succeed at step 1; loser's catch fires `deleteDevice(deviceId)` and also wipes the winner's hub binding |
Yes |
A retry path also matters: if the gateway crashed between step 1 (hub OK) and step 2 (DB OK), the retry hits step 0 → SELECT misses → step 1 → 409 from hub. So the 23505 catch is also reachable via legitimate retries when hub-DB consistency drifts.
What needs to happen
- Confirm the hub keying. Read `ahand-hub-core` admin handler for `POST /api/admin/devices` — does it enforce UNIQUE on `deviceId` only, or on `(deviceId, externalUserId)`?
- If globally unique: this issue is moot. Add a comment in `ahand.service.ts` documenting the invariant so future readers don't worry. Close.
- If per-user: change the compensation strategy to one of:
- re-SELECT the DB row, return idempotent success when owner+pubkey match (treat like step 0 idempotence catching up)
- have the hub expose a (deviceId, externalUserId)-scoped delete so we can target only our own binding
- drop the compensation entirely and accept orphaned hub registrations as a known leak (eventual reconciliation via hub admin)
Test coverage to add once decided
- The `concurrent register collision` scenario in `apps/server/apps/gateway/test/ahand-persistence.e2e-spec.ts` should add an assertion on the `hub.deleteDevice` mock to pin the chosen behaviour.
Acceptance
Related
Context
Surfaced while writing the persistence e2e suite for #73 (commit 93d9a02, the
concurrent register collisionscenario). The suite stubsAhandHubClient, so the production behaviour described below is not directly observed in tests — but the code path is reachable.The race
AhandDevicesService.registerDeviceForUsercatches Postgres UNIQUE violations (code === '23505') and compensates by callinghub.deleteDevice(input.hubDeviceId):```ts
} catch (e) {
if ((e as { code?: string }).code === '23505') {
// Compensate hub registration since the device is already in DB
await this.hub.deleteDevice(input.hubDeviceId).catch(...);
throw new ConflictException('Device already registered');
}
...
}
```
The compensation key is the bare `hubDeviceId` — there's no per-(deviceId, externalUserId) targeting. Whether this is buggy depends on how ahand-hub keys its admin storage:
A retry path also matters: if the gateway crashed between step 1 (hub OK) and step 2 (DB OK), the retry hits step 0 → SELECT misses → step 1 → 409 from hub. So the 23505 catch is also reachable via legitimate retries when hub-DB consistency drifts.
What needs to happen
Test coverage to add once decided
Acceptance
Related