Skip to content

Commit 981a14d

Browse files
Zmatarassoclaude
andcommitted
feat(storage): add atomic setIfAbsent primitive
Adds a race-free "write only if the key is not already set" command to plugin storage: storage.setIfAbsent(key, value, { ttlSeconds? }) plus the pipeline set_if_absent command. Resolves to the stored item on a win, or { value: undefined } when the key already existed (claim lost) — mirroring the get-miss convention. This is the client half; the platform (envoy-web PluginStorageService) must learn the set_if_absent action for it to resolve. See docs/proposals/atomic-set-if-absent.md. Motivation: integrations that provision from two concurrent paths (e.g. an event webhook + an app-extension render, possibly on different pods) need a real mutual-exclusion primitive. Today's get-then-set is a TOCTOU race and set_unique overwrites. setIfAbsent lets exactly one caller win, resolved server-side against the existing unique index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c10aa7 commit 981a14d

6 files changed

Lines changed: 265 additions & 1 deletion

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Design: atomic `set_if_absent` for plugin storage
2+
3+
**Status:** Draft / proposal
4+
**Author:** (via QA harness review of `pacs-integration-service#202`)
5+
**Affected repos:** `envoy-integrations-sdk-nodejs` (client), `envoy-web` (platform storage service)
6+
7+
## Summary
8+
9+
Add a race-free "write only if the key is not already set" primitive to plugin storage:
10+
11+
- **SDK:** `storage.setIfAbsent(key, value, { ttlSeconds? })` and a matching pipeline command
12+
`set_if_absent`.
13+
- **Platform (`envoy-web`):** a new `set_if_absent` action in `Platform::PluginStorageService`
14+
that `INSERT`s and relies on the existing unique index — the DB row *is* the lock. Returns the
15+
item on a win, `null` when the key is already held.
16+
17+
The result: exactly one of N concurrent invocations (across pods) wins the write. This gives
18+
integrations a correct mutual-exclusion / claim primitive that today's API cannot express.
19+
20+
## Motivation
21+
22+
`pacs-integration-service#202` (By The Bay / Habitap) provisions a visitor credential from two
23+
independent code paths that can run concurrently for the same visit:
24+
25+
1. the `invite_created` webhook (for near-term invites), and
26+
2. the `registration_complete_email` **app-extension render** (create-if-missing).
27+
28+
Both do `getVisitor → (null) → createVisitor`. `getVisitor` is a *read*; the record that would make
29+
it idempotent is only written **after** the external `POST`s complete. So two invocations that read
30+
during that window both see "missing" and both create — producing **duplicate Habitap events**, and
31+
because both write the same storage key afterward, the first event is orphaned (storage no longer
32+
references it). The two paths often fire at the same instant (invite creation) and may land on
33+
**different pods**, so in-process de-duplication cannot help.
34+
35+
This is the classic check-then-act (TOCTOU) race. It cannot be fixed with a better *read*: the
36+
guarantee has to live on the *write*.
37+
38+
## Why existing primitives don't solve it
39+
40+
The full storage surface today is `get`, `set`, `set_unique`, `set_unique_num`, `unset`, `list`
41+
(confirmed in SDK `2.5.2` and `envoy-web`'s `PluginStorageService`).
42+
43+
- `set` is an **unconditional** overwrite (last-write-wins) — no contention signal.
44+
- `set_unique` / `set_unique_num` guarantee a unique *generated value* and **overwrite the key**.
45+
The uniqueness is on the value, not on key ownership, so they cannot elect a single winner for a
46+
known key.
47+
- A user-land `get`-then-`set` "lock" reintroduces the exact TOCTOU race and is not cross-pod safe.
48+
- There is no `ttl`/`expire`, and no lock/mutex utility anywhere in the SDK.
49+
50+
Importantly, the backend **already performs atomic conditional writes**: `set_unique` depends on a
51+
`RecordNotUnique` rescue against a unique index (`PluginStorageUniqValue`), and `plugin_storage_items`
52+
already has unique indexes on `(key, plugin_install_id) WHERE archived_at IS NULL` and
53+
`(plugin_id, key)`. `set_if_absent` is a *simpler* use of the same mechanism.
54+
55+
## Proposed API (SDK)
56+
57+
```ts
58+
// EnvoyPluginStorage
59+
setIfAbsent<Value>(key: string, value: Value, options?: { ttlSeconds?: number }):
60+
Promise<EnvoyStorageItem<Value> | { key: string; value: undefined }>;
61+
```
62+
63+
- Resolves to the **stored item** when this call wrote it (claim won).
64+
- Resolves to `{ key, value: undefined }` when the key already existed (claim lost) — mirroring the
65+
existing `get`-miss / `setUnique`-exhaustion convention, so no new result shape is introduced.
66+
- `ttlSeconds` (optional) gives the write an expiry so a crashed holder cannot wedge the key.
67+
68+
Pipeline form, for batching a claim with a follow-up read in one round-trip:
69+
70+
```ts
71+
storage.pipeline()
72+
.setIfAbsent(`provision-lock:${visitId}`, { at: now }, { ttlSeconds: 120 })
73+
.get(`visit:${visitId}`)
74+
.execute();
75+
```
76+
77+
## Wire protocol (`POST /api/v2/plugin-services/storage`)
78+
79+
**Request** (claim):
80+
81+
```json
82+
{
83+
"install_id": "inst_abc123",
84+
"commands": [
85+
{
86+
"action": "set_if_absent",
87+
"key": "provision-lock:invite-222",
88+
"value": { "claimedAt": 1720370000000 },
89+
"ttlSeconds": 120
90+
}
91+
]
92+
}
93+
```
94+
95+
**Response — won** (server wrote the row):
96+
97+
```json
98+
{ "data": [ { "key": "provision-lock:invite-222", "value": { "claimedAt": 1720370000000 } } ] }
99+
```
100+
101+
**Response — lost** (key already held):
102+
103+
```json
104+
{ "data": [ null ] }
105+
```
106+
107+
No controller or strong-params changes are required: `StorageController#pipeline` passes command
108+
hashes through untouched, and results serialize exactly like `get`/`set` (item or `null`).
109+
110+
## Platform implementation (`envoy-web`)
111+
112+
Add one dispatch arm and one method to `Platform::PluginStorageService`. Unlike `set`, it must **not**
113+
`unset` first — the whole point is to fail when the key exists:
114+
115+
```ruby
116+
when 'set_if_absent'
117+
do_storage_item_set_if_absent(command[:key], command[:value])
118+
```
119+
120+
```ruby
121+
# Atomically create an item only if the key is not already set for this scope.
122+
# Relies on the unique index on (key, plugin_install_id) / (plugin_id, key): the INSERT either
123+
# wins or raises RecordNotUnique, which we treat as "already claimed" and return nil. Race-free
124+
# across concurrent requests and pods without an explicit lock — the DB is the lock. Unlike `set`,
125+
# it never overwrites an existing value.
126+
def do_storage_item_set_if_absent(key, value = nil)
127+
plugin_storage_items.create!({ key: key, value: value })
128+
rescue ::ActiveRecord::RecordNotUnique, ::ActiveRecord::RecordInvalid
129+
nil
130+
end
131+
```
132+
133+
This is correct on day one **without TTL**; TTL is a follow-up (below).
134+
135+
## Consumer usage (the fix in `pacs-integration-service`)
136+
137+
```ts
138+
const lock = await pluginClient.storage.setIfAbsent(
139+
`provision-lock:${visit.id}`, { at: Date.now() }, { ttlSeconds: 120 },
140+
);
141+
if (lock.value === undefined) {
142+
return res.sendIgnored('Provisioning already in progress'); // app-ext: poll getVisitor, then render
143+
}
144+
// sole writer for this visit.id — safe to create
145+
let userId = await pluginClient.getVisitor(visit);
146+
if (!userId) userId = await pluginClient.createVisitor(visit);
147+
// permanent idempotency marker stays `visit:{id}`; the lock only guards the create window.
148+
```
149+
150+
The permanent idempotency key remains `visit:{visit.id}`. The claim only serializes the create
151+
window; once the record is written, `getVisitor` short-circuits all future calls.
152+
153+
## TTL follow-up (optional, recommended for locks)
154+
155+
Without an expiry, a holder that crashes **after** claiming but **before** writing the durable record
156+
wedges the key. Options:
157+
158+
1. **Consumer-side release**`unset` the lock in a `finally`. Covers everything except hard pod
159+
death, which the "recoverable create" pattern (persist the external id immediately after the first
160+
external write) already de-fangs by making a re-create resumable rather than duplicative.
161+
2. **Server-side TTL** — add `expires_at` to `plugin_storage_items`, have `set_if_absent` treat an
162+
expired row as absent (delete-then-insert within the rescue, or a partial-unique-index +
163+
sweeper). This makes the lock self-healing and is the clean long-term answer, at the cost of a
164+
migration.
165+
166+
The core PR ships option (1)-compatible behavior (no schema change); (2) can follow once the owning
167+
team weighs the migration.
168+
169+
## Rollout
170+
171+
1. Land the platform `set_if_absent` action in `envoy-web` (backward-compatible; new action only).
172+
2. Release the SDK with `setIfAbsent` (additive; no breaking changes).
173+
3. Adopt in `pacs-integration-service` for BTB provisioning; other integrations can use it for any
174+
claim/mutex need.
175+
176+
Steps 1 and 2 are independent and safe to land in either order; the SDK method is inert until the
177+
platform understands the action.

src/base/EnvoyPluginStoragePipeline.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ export default class EnvoyPluginStoragePipeline {
5858
return this.addCommand({ action: 'set', key, value });
5959
}
6060

61+
/**
62+
* Atomically sets a value for a storage item only if the key is not already set.
63+
* Resolved server-side against a unique index, so it is race-free across concurrent
64+
* invocations and pods. The result is the item when this call wrote it (claim won),
65+
* or null when the key already held a value (claim lost). Pass ttlSeconds to give the
66+
* write an expiry, so a crashed holder cannot wedge the key.
67+
*/
68+
setIfAbsent(key: string, value: unknown, options: { ttlSeconds?: number } = {}): EnvoyPluginStoragePipeline {
69+
return this.addCommand({ action: 'set_if_absent', key, value, ...options });
70+
}
71+
6172
/**
6273
* Sets a unique value for a storage item,
6374
* and returns that item.

src/internal/EnvoyStorageCommand.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface EnvoyStorageSetUniqueNumOptions {
1717
}
1818

1919
export interface EnvoyBaseStorageCommand {
20-
action: 'get' | 'set' | 'set_unique' | 'set_unique_num' | 'unset';
20+
action: 'get' | 'set' | 'set_if_absent' | 'set_unique' | 'set_unique_num' | 'unset';
2121
key: string;
2222
}
2323

@@ -30,6 +30,12 @@ export interface EnvoySetStorageCommand extends EnvoyBaseStorageCommand {
3030
value: unknown;
3131
}
3232

33+
export interface EnvoySetIfAbsentStorageCommand extends EnvoyBaseStorageCommand {
34+
action: 'set_if_absent';
35+
value: unknown;
36+
ttlSeconds?: number;
37+
}
38+
3339
export interface EnvoySetUniqueStorageCommand extends EnvoyBaseStorageCommand, EnvoyStorageSetUniqueOptions {
3440
action: 'set_unique';
3541
}
@@ -53,6 +59,7 @@ export interface EnvoyListStorageCommand {
5359
type EnvoyStorageCommand =
5460
| EnvoyGetStorageCommand
5561
| EnvoySetStorageCommand
62+
| EnvoySetIfAbsentStorageCommand
5663
| EnvoySetUniqueStorageCommand
5764
| EnvoySetUniqueNumStorageCommand
5865
| EnvoyUnsetStorageCommand

src/mocks/EnvoyPluginStoragePipelineMock.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export default class EnvoyPluginStoragePipelineMock extends EnvoyPluginStoragePi
3737
const value = EnvoyPluginStoragePipelineMock.set(command.key, command.value, isGlobal);
3838
return EnvoyPluginStoragePipelineMock.itemFromKeyValue(command.key, value);
3939
}
40+
case 'set_if_absent': {
41+
const written = EnvoyPluginStoragePipelineMock.setIfAbsent(command.key, command.value, isGlobal);
42+
if (written === null) {
43+
return null; // key already existed → claim lost
44+
}
45+
return EnvoyPluginStoragePipelineMock.itemFromKeyValue(command.key, written);
46+
}
4047
case 'set_unique':
4148
try {
4249
const value = EnvoyPluginStoragePipelineMock.setUnique(
@@ -109,6 +116,21 @@ export default class EnvoyPluginStoragePipelineMock extends EnvoyPluginStoragePi
109116
return value;
110117
}
111118

119+
//
120+
// Writes only when the key is absent. Returns the value on a win, or null when the key
121+
// already holds a value (claim lost). The real backend enforces this atomically via a
122+
// unique index; the mock is single-threaded so a plain existence check is equivalent.
123+
// TTL is not modelled in the mock.
124+
//
125+
static setIfAbsent<Value = unknown>(key: string, value: Value, isGlobal = false): Value | null {
126+
key = EnvoyPluginStoragePipelineMock.normalizeKey(key, isGlobal);
127+
if (Object.keys(EnvoyPluginStoragePipelineMock.storage).includes(key)) {
128+
return null;
129+
}
130+
EnvoyPluginStoragePipelineMock.storage[key] = value;
131+
return value;
132+
}
133+
112134
static setUnique(key: string, options = DEFAULT_UNIQUE_OPTIONS, isGlobal = false) {
113135
key = EnvoyPluginStoragePipelineMock.normalizeKey(key, isGlobal);
114136
const chars = options.chars && options.chars.length ? options.chars : UNIQUE_OPTIONS_DEFAULT_CHARS;

src/sdk/EnvoyPluginStorage.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ export default class EnvoyPluginStorage {
4545
return this.pipeline().set(key, value).executeSingle<EnvoyStorageItem<Value>>();
4646
}
4747

48+
/**
49+
* Atomically sets a single {@link EnvoyStorageItem} only if the key is not already set.
50+
*
51+
* Wrapper for single pipeline setIfAbsent. Resolves to the stored item when this call
52+
* won the write, or { value: undefined } when the key already existed.
53+
*/
54+
setIfAbsent<Value = unknown>(key: string, value: Value, options: { ttlSeconds?: number } = {}) {
55+
return this.pipeline()
56+
.setIfAbsent(key, value, options)
57+
.executeSingle<EnvoyStorageItem<Value> | { key: string; value: undefined }>();
58+
}
59+
4860
/**
4961
* Sets a single unique string {@link EnvoyStorageItem} from storage.
5062
*
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import EnvoyPluginStoragePipelineMock from '../../src/mocks/EnvoyPluginStoragePipelineMock';
2+
import EnvoyPluginAPI from '../../src/sdk/EnvoyPluginAPI';
3+
4+
// execute() is overridden in the mock and never touches the API, so a bare stub is enough.
5+
const fakeApi = {} as EnvoyPluginAPI;
6+
const pipeline = () => new EnvoyPluginStoragePipelineMock(fakeApi, 'install-1');
7+
8+
describe('EnvoyPluginStoragePipelineMock setIfAbsent', () => {
9+
beforeEach(() => EnvoyPluginStoragePipelineMock.reset());
10+
11+
it('writes and returns the item when the key is absent (claim won)', async () => {
12+
const result = await pipeline().setIfAbsent('lock:a', { at: 1 }).executeSingle();
13+
expect(result).toEqual({ key: 'lock:a', value: { at: 1 } });
14+
});
15+
16+
it('returns null when the key already exists (claim lost)', async () => {
17+
await pipeline().setIfAbsent('lock:a', { at: 1 }).executeSingle();
18+
const second = await pipeline().setIfAbsent('lock:a', { at: 2 }).executeSingle();
19+
expect(second).toBeNull();
20+
});
21+
22+
it('does not overwrite the existing value on a lost claim', async () => {
23+
await pipeline().setIfAbsent('lock:a', { at: 1 }).executeSingle();
24+
await pipeline().setIfAbsent('lock:a', { at: 2 }).executeSingle();
25+
const current = await pipeline().get('lock:a').executeSingle();
26+
expect(current).toEqual({ key: 'lock:a', value: { at: 1 } });
27+
});
28+
29+
it('lets the key be reclaimed after it is unset (lock release)', async () => {
30+
await pipeline().setIfAbsent('lock:a', { at: 1 }).executeSingle();
31+
await pipeline().unset('lock:a').executeSingle();
32+
const reclaim = await pipeline().setIfAbsent('lock:a', { at: 3 }).executeSingle();
33+
expect(reclaim).toEqual({ key: 'lock:a', value: { at: 3 } });
34+
});
35+
});

0 commit comments

Comments
 (0)