Skip to content

[web-console] Remember support bundles the user opened, in IndexedDB - #6971

Open
Karakatiza666 wants to merge 1 commit into
home-pinned-sectionsfrom
support-bundle-history
Open

[web-console] Remember support bundles the user opened, in IndexedDB#6971
Karakatiza666 wants to merge 1 commit into
home-pinned-sectionsfrom
support-bundle-history

Conversation

@Karakatiza666

@Karakatiza666 Karakatiza666 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

A support bundle opened from disk is now remembered, so the user can reopen it without selecting the file in the file picker again. There are two modes of history, depending on the API, available in the browser: either a reference (handle) to file is stored, or the file itself is cached. The former is more beneficial because it consumes very little browser storage capacity (IndexedDB), but not all browsers provide this handle API.

Browser Entry holds Cost Re-read needs
Chromium (file picker) handle a few hundred B a re-grant
Firefox, Safari (input) file the whole archive nothing

For caching the entire support bundle, reasonable limits of 256 MB per support bundle and 512 MB total are applied - when the bundles no longer fit or are too large they are removed from history.

Describe Manual Test Plan

Automated only, and sufficient here: this PR adds no UI, so there is nothing to drive by hand.

  • bun run test-unit — 76 files, 681 tests pass.
  • bun run check — 0 errors.
  • 32 new tests run against a real IndexedDB, covering the handle round trip, the identity tests that keep a re-picked file from duplicating its entry, both byte limits and the count limit, and the permission queries. cachedBundlesOverBudget is exported so the budget can be exercised without storing half a gigabyte.

Checklist

  • Unit tests added/updated
  • Integration tests added/updated
  • Documentation updated
  • Changelog updated

Breaking Changes?

Mark if you think the answer is yes for any of these components:

  • OpenAPI / REST HTTP API / feldera-types / manager
  • Feldera SQL (Syntax, Semantics)
  • feldera-sqllib (incl. dependencies fxp, etc.)
  • Python SDK
  • fda (CLI arguments)
  • Adapters (including configuration)
  • Storage Format / Checkpoints
  • Others (specify)

Describe Incompatible Changes

None. New browser-local storage; no existing data is read or migrated.

A support bundle opened from disk is now remembered, so the user can reopen it
without hunting for the file again. IndexedDB holds the history because neither
of the two things an entry can carry survives JSON: both a FileSystemFileHandle
and a File are structured-cloneable but not serializable, so localStorage can
hold neither.

Which one an entry carries follows the browser:

  Browser                    Entry holds   Cost               Re-read needs
  Chromium (file picker)     handle        a few hundred B    a re-grant
  Firefox, Safari (input)    file          the whole archive  nothing

A handle is preferred wherever it exists, since it costs the same few hundred
bytes whatever the size of the archive behind it. Browsers with no File System
Access API hand out only a File, which cannot be re-opened from its path, so the
history caches the archive itself. Everything specific to those copies lives in
supportBundleCache, which the history module does not depend on.

Copies are bounded twice: 256 MB per archive, 512 MB across all of them. A
bundle past either limit still opens, it just leaves no history entry, which
beats filling the origin's storage quota with one archive. Both modules cap the
history at 30 entries.

Entries are readable from any tab of this origin, which is what lets one tab link
another to a bundle. Nothing consumes the history yet.

Tests cover both modules against a real IndexedDB: the handle round trip, the
identity tests that keep a re-picked file from duplicating its entry, both
limits, and the permission queries. `cachedBundlesOverBudget` is exported so the
budget can be exercised without storing half a gigabyte.
Comment on lines +26 to +30
/**
* Whether remembering a bundle means caching the archive, because this browser hands
* out no file handles.
*/
export const isBundleCacheRequired = () => !isBundlePickerSupported()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isBundlePickerSupported() folds two independent capabilities into one answer (showOpenFilePicker and indexedDB), and this negation then reads the IndexedDB half backwards.

I confirmed it in a browser test: with indexedDB deleted and no picker, isBundlePickerSupported() === false and isBundleCacheRequired() === true — so a browser with no IndexedDB is told to cache into IndexedDB, and every call below throws ReferenceError on indexedDB.open. Same for a Chromium with site data blocked, where indexedDB.open throws SecurityError.

Suggest splitting the check: isBundlePickerSupported() should ask only about the picker, and a separate isHistorySupported() should ask about IndexedDB, with isBundleCacheRequired() = isHistorySupported() && !isBundlePickerSupported().

Comment on lines +147 to +154
const finished = new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB write failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB write aborted'))
})
const result = await use(transaction.objectStore(STORE_NAME))
await finished
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When use rejects, await use(...) throws and await finished is never reached — but finished already has a reject wired to onerror/onabort, so it rejects with nothing attached. That is an unhandled promise rejection on top of the real error.

Verified with a browser probe that mirrors this shape (a failing request that also aborts the transaction): the caller's promise rejects and window.onunhandledrejection fires with Error: write failed.

Reachable here: a store.put of a cached archive hitting QuotaExceededError errors the request and aborts the transaction. Attaching a no-op catch — finished.catch(() => {}) right after construction, or await Promise.all([use(...), finished]) — closes it.

Comment on lines +248 to +250
const existing = await findSameEntry(handle)
const stored = await putSupportBundle(existing?.id, {
name: handle.name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read-then-write across two transactions, so the dedup is a TOCTOU. The module header advertises cross-tab use ("readable from any tab of this origin"), which is exactly the case where the two callers interleave.

Confirmed in a browser test:

await Promise.all([
  rememberSupportBundle(fakeHandle('same.zip')),
  rememberSupportBundle(fakeHandle('same.zip'))
])
// → ["same.zip", "same.zip"]

Both reads see an empty history, both put with no id, the key generator hands out two ids. rememberSupportBundleFile has the same shape. One readwrite transaction that does the getAll and the put (with findSameEntry's comparison inlined) would hold; if isSameEntry's await makes that impossible, a serializing promise chain in the module would at least cover the single-tab case. Either way, no test covers concurrent calls today.

Comment on lines +97 to +102
export const rememberSupportBundleFile = async (
file: File
): Promise<StoredSupportBundle | null> => {
if (file.size > maxCachedBundleBytes) {
return null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract is "too big to cache → null, the bundle still opens", but the browser's own quota is a third limit that is not handled: a 256 MB archive that overruns the origin quota makes putSupportBundle reject, so the caller in the next PR loses the bundle rather than just the history entry. Wrapping the store in try/catch and returning null on QuotaExceededError would make the two limits behave the same way. Worth a test too — no test drives a failing write.

Comment on lines +50 to +52
/**
* The cached copies that exceed the byte budget, oldest first. Entries holding a
* handle weigh nothing, so they are passed over.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "oldest first" is the reverse of what comes back. The loop walks mostRecentFirst, so excess is newest-first too. Deletion does not care, but the test named "drops the oldest copies once they outgrow the budget" only passes because its expectation has one element — it would not catch the order the doc claims.

export const resolveStoredBundle = async (
id: number | undefined
): Promise<{ bundle: StoredSupportBundle; needsPermission: boolean }> => {
const bundle = id ? await getSupportBundle(id) : undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: id ? treats 0 as absent. IndexedDB's key generator starts at 1, so nothing produces it today, but id !== undefined says what is meant and does not depend on that.

@mythical-fred-oss

Copy link
Copy Markdown

Nice module: the two-kinds-of-entry split is well chosen, the comments explain why rather than what, and the commit message is a model of the form. Ran locally in js-packages/web-console: bun install --frozen-lockfile, then bun run test-unit → 648/649 pass; the one failure is MetricsTables.svelte.spec.ts under full-suite load, which passes on its own and is unrelated to this PR. bun run check → 0 errors. pre-commit run --files <the 4 files> → JavaScript Check passed. I also re-ran the two new spec files 6× to look for flakes — stable, and a probe confirmed vitest gives each browser test file its own IndexedDB partition, so the two specs sharing feldera-support-bundles is safe.

Three findings I reproduced in the browser (details inline): isBundleCacheRequired() returns true when IndexedDB is missing, withStore leaks an unhandled rejection when a request fails, and two concurrent rememberSupportBundle calls of the same file produce duplicate entries.

Gates:

Gate Verdict
Unit tests for changed behaviour pass — 32 tests against a real IndexedDB
Flaky tests pass
SQL type coverage n/a
unsafe / SAFETY n/a
Breaking changes n/a — new browser-local store
Docs n/a — no UI yet; please cover it when the callers land
Dependencies / licensing n/a — none added
CI workflows n/a

Uncovered cases beyond the inline ones: pickSupportBundle()'s early return null when showOpenFilePicker is absent; isBundlePickerSupported()'s false branches; touchSupportBundle on a cached entry (it re-puts the whole File just to bump openedAt — worth checking Chromium does not rewrite up to 256 MB of blob); and a history holding both linked and cached entries while pruning. One thing for the follow-up PR rather than this one: support bundles carry logs and config, and nothing clears this store on logout — clearSupportBundles exists but has no caller.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant