mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
3 Commits
pysssss/pr
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a9d3ec02c | ||
|
|
15dbec71c3 | ||
|
|
042966ada3 |
@@ -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 &&
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user