Skip to content

ahand: hub.deleteDevice compensation can delete the winner's hub registration in concurrent register race #78

Description

@winrey

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

  1. 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)`?
  2. If globally unique: this issue is moot. Add a comment in `ahand.service.ts` documenting the invariant so future readers don't worry. Close.
  3. 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

  • Hub keying invariant confirmed (in code or in comment)
  • Compensation strategy aligned with that invariant
  • e2e suite updated to cover the chosen behaviour

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions