Skip to content
Draft
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
38 changes: 38 additions & 0 deletions .changeset/driver-web-cache-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@fluidframework/driver-web-cache": minor
"__section": legacy
---
Add cross-instance change notifications to `FluidCache`

`FluidCache` now broadcasts cache mutations to other `FluidCache` instances in the same browsing context (typically other browser tabs of the same origin) via a `BroadcastChannel`.
Consumers can subscribe through the new `events` property, which is a standard Fluid Framework `Listenable`:

```ts
const unsubscribe = fluidCache.events.on("change", (event) => {
if (event.type === "removeFile") {
// All entries for event.fileId were dropped by another tab.
} else {
// event.type is "put" or "remove"; event.partitionKey matches this cache's partition.
// event.fileId, event.entryType, event.cacheItemId describe the affected entry.
}
});
// Later:
unsubscribe();
```

Notification semantics:

- `put` and `remove` events are filtered by partition key.
A listener only receives events whose `partitionKey` matches the partition key of its `FluidCache`, consistent with the semantics of `get`.
The events fire from `put`, a successful `putIf`, and `removeEntry`.
- `removeFile` events fire from `removeEntries` and are delivered to every listener regardless of partition, because `removeEntries` drops rows regardless of partition.
- `BroadcastChannel` does not echo a message back to the instance that posted it, so a write performed by this `FluidCache` does not invoke its own listeners.
Other instances (including ones in the same tab) do.
- If `BroadcastChannel` is unavailable in the runtime, the `events` subscription becomes a no-op and writes do not broadcast.
The constructor emits a one-shot `FluidCacheBroadcastChannelUnavailable` telemetry event in that case so hosts can detect the degraded mode.

The cache now also exposes a `dispose()` method, which closes the `BroadcastChannel`, the open IndexedDB connection, and the close timer.
`dispose` is idempotent.
After `dispose` returns, every other public method (`get`, `put`, `putIf`, `removeEntry`, `removeEntries`) throws a `UsageError`.
Operations that were already in flight when `dispose` was called also reject with a `UsageError`, and the underlying IndexedDB connection is not lazily reopened by any such in-flight call.

Check warning on line 37 in .changeset/driver-web-cache-events.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Adverbs] Remove 'lazily' if it's not important to the meaning of the statement. Raw Output: {"message": "[Microsoft.Adverbs] Remove 'lazily' if it's not important to the meaning of the statement.", "location": {"path": ".changeset/driver-web-cache-events.md", "range": {"start": {"line": 37, "column": 146}}}, "severity": "WARNING"}
Subscribing through `events` on a disposed cache is permitted but no events will fire.
27 changes: 27 additions & 0 deletions .changeset/optimistic-driver-web-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@fluidframework/driver-web-cache": minor
"__section": legacy
---
Add `FluidCache.putIf` for compare-and-swap writes

`FluidCache` now exposes `putIf(entry, value, shouldWrite)`, a conditional variant of `put`.
The caller decides, based on what is currently cached, whether the new value should overwrite the existing one.
The read of the existing entry and the conditional write happen in a single IndexedDB `readwrite` transaction.
This provides compare-and-swap semantics across consumers sharing the same underlying IndexedDB instance (for example, multiple browser tabs racing to persist offline pending state).

```ts
const wrote = await fluidCache.putIf(entry, proposed, (existing, prop) => {
// existing is undefined if no entry exists for this key in this partition.
const existingRev = (existing as { rev?: number } | undefined)?.rev ?? -1;
return (prop as { rev: number }).rev > existingRev;
});
```

The `shouldWrite` predicate must be synchronous.
IndexedDB transactions close automatically on any non-IndexedDB await, which would break the atomicity that makes the compare-and-swap correct.
The predicate is invoked with `(existing, proposed)`.

Check warning on line 22 in .changeset/optimistic-driver-web-cache.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.SentenceLength] Try to keep sentences short (< 30 words). Raw Output: {"message": "[Microsoft.SentenceLength] Try to keep sentences short (\u003c 30 words).", "location": {"path": ".changeset/optimistic-driver-web-cache.md", "range": {"start": {"line": 22, "column": 15}}}, "severity": "INFO"}
`existing` is `undefined` in any case where the row would not be visible to `get`: no entry exists for the key, the existing entry belongs to a different partition, or the existing entry is older than `maxCacheItemAge`.
The call returns `true` if the new value was written and `false` if the predicate rejected the write or an error occurred.
Errors are logged and not thrown, matching `put`.
When the predicate returns `true`, the write proceeds and atomically replaces whatever row exists at the key, including cross-partition or stale rows that the predicate saw as `undefined`.
This matches the unconditional overwrite behavior of `put`.
65 changes: 65 additions & 0 deletions packages/drivers/driver-web-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,71 @@ new FluidCache({
for a cache entry to be used. This flag does not control when cached content is deleted since different scenarios and
applications may have different staleness thresholds for the same data.

## Conditional writes (`putIf`)

`FluidCache` exposes a `putIf` method that performs a compare-and-swap write. The currently-cached value
is read and (if the caller-supplied predicate returns `true`) the new value is written inside a single
IndexedDB `readwrite` transaction, giving conditional-write semantics across consumers sharing the same
underlying IndexedDB instance (e.g. multiple browser tabs racing to persist offline pending state).

```typescript
const wrote = await fluidCache.putIf(entry, proposed, (existing, prop) => {
// `existing` is `undefined` if no entry exists for this key in this partition.
const existingRev = (existing as { rev?: number } | undefined)?.rev ?? -1;
return (prop as { rev: number }).rev > existingRev;
});
```

The `shouldWrite` predicate must be synchronous — IndexedDB transactions auto-close on any non-IDB
await, which would silently break the atomicity that makes the compare-and-swap correct. The predicate
is invoked with `(existing, proposed)`; `existing` is `undefined` when the cached row would be invisible
to `get` — that is, no entry exists for the key, the existing entry belongs to a different partition,
or the existing entry is older than `maxCacheItemAge`. When the predicate returns `true`, the write
always proceeds and atomically replaces whatever row sits at the key, including cross-partition or
stale rows the predicate saw as `undefined` (matching the unconditional overwrite behavior of `put`).
The call returns `true` if the new value was written and `false` if the predicate rejected the write
or an error occurred.

## Cross-instance change notifications (`events`)

`FluidCache` broadcasts cache mutations over a `BroadcastChannel`, so other `FluidCache` instances
in the same browsing context (typically other tabs of the same origin) can observe changes made
elsewhere. Subscribe through the `events` property, which is a standard Fluid Framework
[`Listenable`](https://fluidframework.com/docs/api/core-interfaces/listenable-interface):

```typescript
const unsubscribe = fluidCache.events.on("change", (event) => {
if (event.type === "removeFile") {
// All entries for this file were dropped by some other tab.
} else {
// event.type is "put" or "remove"; event.partitionKey matches this cache's partition.
// event.entryType carries the cache entry's category (for example "snapshot").
}
});
// Later:
unsubscribe();
```

Per-entry `put` and `remove` events are filtered by partition key (you only receive events whose
`partitionKey` matches this cache's). `removeFile` events are delivered unconditionally because
`removeEntries` drops rows regardless of partition.

Note: `BroadcastChannel` does not echo a message back to the instance that posted it, so writes
performed by *this* `FluidCache` do not invoke its own listeners — only other instances do.

When the cache is no longer needed (e.g. user signs out, page unloads), call `fluidCache.dispose()`
to close the `BroadcastChannel` and any open IndexedDB connection. `dispose` is idempotent. After
`dispose` returns, every other public method (`get`, `put`, `putIf`, `removeEntry`,
`removeEntries`) throws a `UsageError`. Operations that were already in flight when `dispose` was
called also reject with a `UsageError`, and the underlying IndexedDB connection is not lazily
reopened by any such in-flight call. Subscribing to `events` after `dispose` is permitted, but no
events will fire.

If `BroadcastChannel` is not available in the runtime, the `events` subscription becomes a no-op
and writes simply don't broadcast. The constructor emits a one-shot
`FluidCacheBroadcastChannelUnavailable` telemetry event in that case so hosts can detect the
degraded mode.

## Clearing cache entries

Whenever any Fluid content is loaded with the web cache enabled, a task is scheduled to clear out all "stale" cache
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,31 @@ export function deleteFluidCacheIndexDbInstance(deleteDBCallbacks?: DeleteDBCall
// @beta @legacy
export class FluidCache implements IPersistedCache {
constructor(config: FluidCacheConfig);
dispose(): void;
get events(): Listenable<FluidCacheEvents>;
// (undocumented)
get(cacheEntry: ICacheEntry): Promise<any>;
// (undocumented)
put(entry: ICacheEntry, value: any): Promise<void>;
putIf(entry: ICacheEntry, value: unknown, shouldWrite: (existing: unknown, proposed: unknown) => boolean): Promise<boolean>;
// (undocumented)
removeEntries(file: IFileEntry): Promise<void>;
// (undocumented)
removeEntry(entry: ICacheEntry): Promise<void>;
}

// @beta @legacy
export type FluidCacheChangeEvent = {
readonly type: "put" | "remove";
readonly partitionKey: string | null;
readonly fileId: string;
readonly entryType: string;
readonly cacheItemId: string;
} | {
readonly type: "removeFile";
readonly fileId: string;
};

// @beta @legacy (undocumented)
export interface FluidCacheConfig {
closeDbAfterMs?: number;
Expand All @@ -28,6 +43,12 @@ export interface FluidCacheConfig {
partitionKey: string | null;
}

// @beta @legacy
export interface FluidCacheEvents {
// (undocumented)
change: (event: FluidCacheChangeEvent) => void;
}

// (No @packageDocumentation comment for this package)

```
7 changes: 6 additions & 1 deletion packages/drivers/driver-web-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"typetests:gen": "flub generate typetests --dir . -v"
},
"dependencies": {
"@fluid-internal/client-utils": "workspace:~",
"@fluidframework/core-interfaces": "workspace:~",
"@fluidframework/core-utils": "workspace:~",
"@fluidframework/driver-definitions": "workspace:~",
Expand Down Expand Up @@ -121,7 +122,11 @@
"typescript": "~5.4.5"
},
"typeValidation": {
"broken": {},
"broken": {
"Class_FluidCache": {
"forwardCompat": false
}
},
"entrypoint": "legacy"
}
}
Loading
Loading