Compare commits

...

3 Commits

Author SHA1 Message Date
Glary-Bot
4a9d3ec02c address review: defer overflow-eviction payload deletes until index write succeeds
CodeRabbit follow-up on #11818:

The MAX_DRAFTS overflow path in saveDraft was deleting evicted
payloads BEFORE persisting newIndex. If persistIndex(newIndex) then
failed, my prior commit's rollback (persistIndex(index)) would restore
an in-memory index pointing at payloads we'd already deleted —
trading one desync for another.

Defer deletePayloads(evicted) until after persistIndex succeeds. On
failure we now leave evicted payloads on disk, the original index is
restored, and a subsequent successful save will redo the eviction
cleanly. The handleQuotaExceeded path is intentionally NOT changed
here: under quota pressure we MUST delete bytes before retrying
writePayload, so eviction is structurally non-deferrable in that path.

Test added asserting the evicted payload survives an index write
failure and is still loadable through the store API.
2026-05-02 02:43:13 +00:00
Glary-Bot
15dbec71c3 address review: persist post-eviction index on failed index write; report serialized payload byte size
CodeRabbit feedback on #11818:

- handleQuotaExceeded retry path: when writePayload succeeds but the
  follow-up persistIndex(finalIndex) fails, we already deleted N old
  payloads. Persist the post-eviction currentIndex so localStorage
  doesn't keep referring to those removed payloads. Apply the same
  rollback to the equivalent code path in saveDraft itself for
  symmetry — same desync shape exists there.

- Sentry incomingPayloadBytes: data.length counts UTF-16 code units of
  the bare workflow string. The actual stored value is
  JSON.stringify({ data, updatedAt }) and quota is enforced in UTF-8
  bytes on most engines. Replace with a TextEncoder-based byte count
  of the serialized envelope.

Tests added for both: index rollback after final write failure, and
byte-size telemetry for non-ASCII payloads.
2026-05-02 02:36:25 +00:00
Glary-Bot
042966ada3 fix(persistence): tolerate index desync and report quota exhaustion in draft eviction
When localStorage hits quota, handleQuotaExceeded walks the LRU order and
evicts older drafts one at a time. The previous implementation matched the
oldest order key against entries via Object.values(...).find(...), which
both performed an unnecessary linear scan and broke the loop early on any
orphan in index.order whose hash had no matching entry. That left
evictable drafts in place and silently fell through to
markStorageUnavailable, after which every subsequent saveDraft failed
until reload.

- Look up entries by hash key directly (the entries map is already keyed
  by hashPath(path)).
- Strip orphan order keys in place and continue the eviction loop.
- Persist the cleaned index even when eviction ultimately fails, so the
  desync is repaired and not re-encountered on the next attempt.
- Report a one-shot Sentry warning with eviction stats when storage is
  exhausted despite a full eviction sweep, so we can confirm in
  production whether the orphan path was actually being hit.

Adds resetStorageAvailable() to storageIO so tests (and future recovery
logic) can clear the sticky module-level flag.
2026-05-02 02:15:59 +00:00
3 changed files with 304 additions and 23 deletions

View File

@@ -23,6 +23,10 @@ export function markStorageUnavailable(): void {
storageAvailable = false
}
export function resetStorageAvailable(): void {
storageAvailable = true
}
function isQuotaExceeded(error: unknown): boolean {
return (
error instanceof DOMException &&

View File

@@ -3,6 +3,8 @@ import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DRAFTS } from '../base/draftTypes'
import { hashPath } from '../base/hashUtil'
import { resetStorageAvailable } from '../base/storageIO'
import { useWorkflowDraftStoreV2 } from './workflowDraftStoreV2'
vi.mock('@/scripts/api', () => ({
@@ -18,11 +20,22 @@ vi.mock('@/scripts/app', () => ({
}
}))
const captureMessageMock = vi.hoisted(() => vi.fn())
vi.mock('@sentry/vue', () => ({
captureMessage: captureMessageMock
}))
function quotaError(): DOMException {
const err = new DOMException('Quota exceeded', 'QuotaExceededError')
return err
}
describe('workflowDraftStoreV2', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
sessionStorage.clear()
resetStorageAvailable()
vi.clearAllMocks()
})
@@ -98,6 +111,238 @@ describe('workflowDraftStoreV2', () => {
expect(store.getDraft('workflows/draft0.json')).toBeNull()
expect(store.getDraft('workflows/new.json')).not.toBeNull()
})
it('keeps overflow-evicted payloads on disk when the index write fails', () => {
const indexKey = 'Comfy.Workflow.DraftIndex.v2:personal'
const payloadPrefix = 'Comfy.Workflow.Draft.v2:personal:'
const store = useWorkflowDraftStoreV2()
for (let i = 0; i < MAX_DRAFTS; i++) {
store.saveDraft(`workflows/draft${i}.json`, `{"id":${i}}`, {
name: `draft${i}`,
isTemporary: true
})
}
const evictedPayloadKey = `${payloadPrefix}${hashPath('workflows/draft0.json')}`
expect(localStorage.getItem(evictedPayloadKey)).not.toBeNull()
const realSetItem = localStorage.setItem.bind(localStorage)
const setItemSpy = vi
.spyOn(localStorage, 'setItem')
.mockImplementation((key: string, value: string) => {
if (
key === indexKey &&
JSON.parse(value).entries[hashPath('workflows/overflow.json')]
) {
throw quotaError()
}
return realSetItem(key, value)
})
try {
const ok = store.saveDraft('workflows/overflow.json', '{"id":"new"}', {
name: 'overflow',
isTemporary: true
})
expect(ok).toBe(false)
} finally {
setItemSpy.mockRestore()
}
expect(localStorage.getItem(evictedPayloadKey)).not.toBeNull()
expect(store.getDraft('workflows/draft0.json')).not.toBeNull()
expect(store.getDraft('workflows/overflow.json')).toBeNull()
})
})
describe('handleQuotaExceeded', () => {
const indexKey = 'Comfy.Workflow.DraftIndex.v2:personal'
const payloadPrefix = 'Comfy.Workflow.Draft.v2:personal:'
function seedDraftDirect(path: string, data: string, name: string) {
const key = hashPath(path)
localStorage.setItem(
`${payloadPrefix}${key}`,
JSON.stringify({ data, updatedAt: Date.now() })
)
const json = localStorage.getItem(indexKey)
const index = json
? JSON.parse(json)
: { v: 2, updatedAt: Date.now(), order: [], entries: {} }
if (!index.order.includes(key)) index.order.push(key)
index.entries[key] = {
path,
name,
isTemporary: true,
updatedAt: Date.now()
}
localStorage.setItem(indexKey, JSON.stringify(index))
}
function injectOrphanedOrderKeys(...orphanKeys: string[]) {
const json = localStorage.getItem(indexKey)
const index = json
? JSON.parse(json)
: { v: 2, updatedAt: Date.now(), order: [], entries: {} }
index.order = [...orphanKeys, ...index.order]
localStorage.setItem(indexKey, JSON.stringify(index))
}
function spyQuotaOnPayloadWrite(failTimes = Infinity) {
const realSetItem = localStorage.setItem.bind(localStorage)
let failed = 0
return vi
.spyOn(localStorage, 'setItem')
.mockImplementation((key: string, value: string) => {
if (key.startsWith(payloadPrefix) && failed < failTimes) {
failed++
throw quotaError()
}
return realSetItem(key, value)
})
}
it('continues eviction past orphaned order keys with no entry', () => {
const store = useWorkflowDraftStoreV2()
seedDraftDirect('workflows/evictable.json', '{"id":1}', 'evictable')
const desyncedKey = 'deadbeef'
injectOrphanedOrderKeys(desyncedKey)
const setItemSpy = spyQuotaOnPayloadWrite(1)
try {
const ok = store.saveDraft('workflows/incoming.json', '{"id":"new"}', {
name: 'incoming',
isTemporary: true
})
expect(ok).toBe(true)
} finally {
setItemSpy.mockRestore()
}
expect(store.getDraft('workflows/evictable.json')).toBeNull()
expect(store.getDraft('workflows/incoming.json')).not.toBeNull()
const finalIndex = JSON.parse(localStorage.getItem(indexKey)!)
expect(finalIndex.order).not.toContain(desyncedKey)
})
it('cleans up multiple orphaned order keys preceding eviction candidates', () => {
const store = useWorkflowDraftStoreV2()
seedDraftDirect('workflows/a.json', '{"id":"a"}', 'a')
seedDraftDirect('workflows/b.json', '{"id":"b"}', 'b')
injectOrphanedOrderKeys('orphan01', 'orphan02')
const setItemSpy = spyQuotaOnPayloadWrite(1)
try {
const ok = store.saveDraft('workflows/c.json', '{"id":"c"}', {
name: 'c',
isTemporary: true
})
expect(ok).toBe(true)
} finally {
setItemSpy.mockRestore()
}
const finalIndex = JSON.parse(localStorage.getItem(indexKey)!)
expect(finalIndex.order).not.toContain('orphan01')
expect(finalIndex.order).not.toContain('orphan02')
expect(store.getDraft('workflows/a.json')).toBeNull()
expect(store.getDraft('workflows/b.json')).not.toBeNull()
expect(store.getDraft('workflows/c.json')).not.toBeNull()
})
it('reports to Sentry when storage fills despite full eviction', () => {
const store = useWorkflowDraftStoreV2()
seedDraftDirect('workflows/a.json', '{"id":"a"}', 'a')
const setItemSpy = spyQuotaOnPayloadWrite()
try {
const ok = store.saveDraft('workflows/incoming.json', '{"id":"new"}', {
name: 'incoming',
isTemporary: true
})
expect(ok).toBe(false)
} finally {
setItemSpy.mockRestore()
}
expect(captureMessageMock).toHaveBeenCalledWith(
expect.stringContaining('localStorage quota exhausted'),
expect.objectContaining({
level: 'warning',
tags: expect.objectContaining({
error_type: 'storage_quota_exhausted',
store: 'workflowDraftStoreV2'
})
})
)
})
it('reports payload byte size measured against the serialized envelope', () => {
const store = useWorkflowDraftStoreV2()
const data = '{"emoji":"🚀","note":"€"}'
const setItemSpy = spyQuotaOnPayloadWrite()
try {
store.saveDraft('workflows/multibyte.json', data, {
name: 'mb',
isTemporary: true
})
} finally {
setItemSpy.mockRestore()
}
const envelope = JSON.stringify({ data, updatedAt: 0 })
const expectedBytes = new TextEncoder().encode(envelope).length
expect(expectedBytes).toBeGreaterThan(data.length)
const call = captureMessageMock.mock.calls.find(
([msg]) =>
typeof msg === 'string' &&
msg.includes('localStorage quota exhausted')
)
expect(call?.[1]?.extra?.incomingPayloadBytes).toBe(expectedBytes)
})
it('rolls the persisted index back when the final index write fails after eviction', () => {
const store = useWorkflowDraftStoreV2()
seedDraftDirect('workflows/a.json', '{"id":"a"}', 'a')
const realSetItem = localStorage.setItem.bind(localStorage)
let payloadFailures = 0
const setItemSpy = vi
.spyOn(localStorage, 'setItem')
.mockImplementation((key: string, value: string) => {
if (key.startsWith(payloadPrefix) && payloadFailures === 0) {
payloadFailures++
throw quotaError()
}
if (
key === indexKey &&
JSON.parse(value).entries[hashPath('workflows/incoming.json')]
) {
throw quotaError()
}
return realSetItem(key, value)
})
try {
const ok = store.saveDraft('workflows/incoming.json', '{"id":"new"}', {
name: 'incoming',
isTemporary: true
})
expect(ok).toBe(false)
} finally {
setItemSpy.mockRestore()
}
const persisted = JSON.parse(localStorage.getItem(indexKey)!)
expect(persisted.order).not.toContain(hashPath('workflows/incoming.json'))
expect(persisted.order).not.toContain(hashPath('workflows/a.json'))
expect(store.getDraft('workflows/incoming.json')).toBeNull()
})
})
describe('removeDraft', () => {

View File

@@ -5,6 +5,7 @@
* Handles LRU eviction and quota management.
*/
import { captureMessage } from '@sentry/vue'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -131,21 +132,22 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
MAX_DRAFTS
)
// Delete evicted payloads
deletePayloads(workspaceId, evicted)
// Persist index
if (!persistIndex(newIndex)) {
// Index write failed - try to recover
deletePayload(workspaceId, draftKey)
persistIndex(index)
return false
}
deletePayloads(workspaceId, evicted)
return true
}
/**
* Handles quota exceeded by evicting oldest drafts until write succeeds.
*
* Tolerates index/payload desync: orphaned `order` keys with no matching
* entry in `entries` are stripped in-place and the loop continues, rather
* than bailing out and leaving evictable drafts behind.
*/
function handleQuotaExceeded(
path: string,
@@ -153,35 +155,31 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
meta: DraftMeta
): boolean {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const draftKey = hashPath(path)
// Try evicting oldest entries until we can write
let currentIndex = index
let currentIndex = loadIndex()
let evictedCount = 0
while (currentIndex.order.length > 0) {
const oldestKey = currentIndex.order.find((key) => key !== draftKey)
if (!oldestKey) break // Only the target draft remains
if (!oldestKey) break
// Evict oldest
const oldestEntry = Object.values(currentIndex.entries).find(
(e) => hashPath(e.path) === oldestKey
)
if (!oldestEntry) break
const oldestEntry = currentIndex.entries[oldestKey]
if (!oldestEntry) {
currentIndex = stripOrderKey(currentIndex, oldestKey)
continue
}
const result = removeEntry(currentIndex, oldestEntry.path)
currentIndex = result.index
if (result.removedKey) {
deletePayload(workspaceId, result.removedKey)
evictedCount++
}
// Try writing again
const success = writePayload(workspaceId, draftKey, {
data,
updatedAt: Date.now()
})
if (success) {
// Update index with the new entry
if (
writePayload(workspaceId, draftKey, { data, updatedAt: Date.now() })
) {
const { index: finalIndex } = upsertEntry(
currentIndex,
path,
@@ -190,17 +188,51 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
)
if (!persistIndex(finalIndex)) {
deletePayload(workspaceId, draftKey)
persistIndex(currentIndex)
return false
}
return true
}
}
// All evictions failed - mark storage as unavailable
persistIndex(currentIndex)
reportQuotaExhausted(currentIndex, evictedCount, payloadByteSize(data))
markStorageUnavailable()
return false
}
function payloadByteSize(data: string): number {
return new TextEncoder().encode(JSON.stringify({ data, updatedAt: 0 }))
.length
}
function stripOrderKey(index: DraftIndexV2, orphanKey: string): DraftIndexV2 {
return {
...index,
updatedAt: Date.now(),
order: index.order.filter((key) => key !== orphanKey)
}
}
function reportQuotaExhausted(
finalIndex: DraftIndexV2,
evicted: number,
payloadBytes: number
): void {
captureMessage('localStorage quota exhausted after full draft eviction', {
level: 'warning',
tags: {
error_type: 'storage_quota_exhausted',
store: 'workflowDraftStoreV2'
},
extra: {
evictedDrafts: evicted,
remainingDrafts: finalIndex.order.length,
incomingPayloadBytes: payloadBytes
}
})
}
/**
* Removes a draft.
*/