refactor: simplify session storage service

- Rename to workflowSessionStorageService with semantic names
- Remove verbose JSDoc and byte tracking
- Streamline logging to specific error cases
- Simplify eviction flow (280 → 115 lines)

Amp-Thread-ID: https://ampcode.com/threads/T-019bc597-fbea-778a-ae15-92061b3d0812
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
bymyself
2026-01-15 23:04:51 -08:00
parent 78e2d702de
commit add7a4fbd4
4 changed files with 213 additions and 394 deletions

View File

@@ -16,10 +16,9 @@ import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import {
getStorageStats,
getWithLruTracking,
setWithLruEviction
} from '@/platform/workflow/persistence/services/sessionStorageLruService'
getWithAccessTracking,
setWithEviction
} from '@/platform/workflow/persistence/services/workflowSessionStorageService'
import { useCommandStore } from '@/stores/commandStore'
const WORKFLOW_KEY_PATTERN = /^workflow:/
@@ -66,22 +65,7 @@ export function useWorkflowPersistence() {
if (api.clientId) {
const key = `workflow:${api.clientId}`
const success = setWithLruEviction(
key,
workflowData,
WORKFLOW_KEY_PATTERN
)
if (!success) {
const stats = getStorageStats(WORKFLOW_KEY_PATTERN)
console.warn(
'[WorkflowPersistence] Failed to persist workflow after LRU eviction',
{
workflowSizeKB: Math.round(workflowJson.length / 1024),
...stats
}
)
}
setWithEviction(key, workflowData, WORKFLOW_KEY_PATTERN)
}
}
@@ -98,9 +82,9 @@ export function useWorkflowPersistence() {
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
const clientId = api.initialClientId ?? api.clientId
// Try loading from session storage first (uses LRU tracking)
// Try loading from session storage first
if (clientId) {
const sessionWorkflow = getWithLruTracking<ComfyWorkflowJSON>(
const sessionWorkflow = getWithAccessTracking<ComfyWorkflowJSON>(
`workflow:${clientId}`
)
if (await loadWorkflowFromData(sessionWorkflow, workflowName)) {

View File

@@ -1,280 +0,0 @@
/**
* @fileoverview LRU-based session storage service with automatic eviction
* @module services/sessionStorageLruService
*
* Provides session storage operations with:
* - LRU tracking via embedded timestamps
* - Automatic eviction on quota exceeded errors
* - Graceful degradation when storage is unavailable
* - Backward compatibility with legacy (unwrapped) data
*
* @deprecated-notice Legacy format support (unwrapped data) can be removed after 2026-07-15
*/
interface StorageEntry<T> {
accessedAt: number
data: T
}
interface EvictableEntry {
key: string
accessedAt: number
size: number
}
const MAX_EVICTION_ATTEMPTS = 3
const PROTECTED_KEY_PREFIXES = ['workspace.', 'Workspace.']
function isProtectedKey(key: string): boolean {
return PROTECTED_KEY_PREFIXES.some((prefix) => key.includes(prefix))
}
/**
* Checks if parsed data is in legacy format (raw data without wrapper)
* Legacy format: raw workflow JSON with nodes/links at top level
* New format: { accessedAt: number, data: T }
*
* @deprecated Remove after 2026-07-15
*/
function isLegacyFormat(parsed: unknown): boolean {
if (typeof parsed !== 'object' || parsed === null) return true
const obj = parsed as Record<string, unknown>
return !('accessedAt' in obj && 'data' in obj)
}
/**
* Wraps data with LRU metadata for storage
*/
function wrapForStorage<T>(data: T): string {
const entry: StorageEntry<T> = {
accessedAt: Date.now(),
data
}
return JSON.stringify(entry)
}
/**
* Unwraps stored data, handling both legacy and new formats
* Legacy entries are assigned accessedAt: 0 to prioritize them for eviction
*
* @deprecated Legacy handling can be removed after 2026-07-15
*/
function unwrapFromStorage<T>(raw: string): StorageEntry<T> {
const parsed = JSON.parse(raw)
if (isLegacyFormat(parsed)) {
return {
accessedAt: 0,
data: parsed as T
}
}
return parsed as StorageEntry<T>
}
/**
* Gets all evictable entries from session storage matching a key pattern
*/
function getEvictableEntries(keyPattern: RegExp): EvictableEntry[] {
const entries: EvictableEntry[] = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (!key || !keyPattern.test(key) || isProtectedKey(key)) continue
const raw = sessionStorage.getItem(key)
if (!raw) continue
try {
const entry = unwrapFromStorage(raw)
entries.push({
key,
accessedAt: entry.accessedAt,
size: raw.length
})
} catch {
entries.push({
key,
accessedAt: 0,
size: raw.length
})
}
}
return entries
}
/**
* Evicts the least recently used entries matching a pattern
* @returns Number of bytes freed
*/
function evictLruEntries(
keyPattern: RegExp,
excludeKey?: string,
maxToEvict: number = 1
): number {
const entries = getEvictableEntries(keyPattern)
.filter((e) => e.key !== excludeKey)
.sort((a, b) => a.accessedAt - b.accessedAt)
let freedBytes = 0
const toEvict = entries.slice(0, maxToEvict)
for (const entry of toEvict) {
sessionStorage.removeItem(entry.key)
freedBytes += entry.size
}
if (toEvict.length > 0) {
console.warn(
`[SessionStorageLRU] Evicted ${toEvict.length} entries, freed ~${Math.round(freedBytes / 1024)}KB`,
toEvict.map((e) => e.key)
)
}
return freedBytes
}
/**
* Attempts to set a session storage item with LRU eviction on quota exceeded
*
* @param key - Storage key
* @param data - Data to store (will be wrapped with LRU metadata)
* @param evictionPattern - Regex pattern for keys eligible for eviction
* @returns true if stored successfully, false if storage failed after retries
*/
export function setWithLruEviction<T>(
key: string,
data: T,
evictionPattern: RegExp = /^workflow:/
): boolean {
const wrapped = wrapForStorage(data)
for (let attempt = 0; attempt <= MAX_EVICTION_ATTEMPTS; attempt++) {
try {
sessionStorage.setItem(key, wrapped)
return true
} catch (error) {
if (
!(error instanceof DOMException) ||
error.name !== 'QuotaExceededError'
) {
console.error('[SessionStorageLRU] Unexpected storage error:', error)
return false
}
if (attempt === MAX_EVICTION_ATTEMPTS) {
console.warn(
`[SessionStorageLRU] Failed to store ${key} after ${MAX_EVICTION_ATTEMPTS} eviction attempts`
)
return false
}
const entriesToEvict = Math.min(attempt + 1, 3)
const freedBytes = evictLruEntries(evictionPattern, key, entriesToEvict)
if (freedBytes === 0) {
console.warn(
'[SessionStorageLRU] No entries available for eviction, giving up'
)
return false
}
}
}
return false
}
/**
* Gets data from session storage, updating access time for LRU tracking
*
* @param key - Storage key
* @param updateAccessTime - Whether to update the access timestamp (default: true)
* @returns The stored data, or null if not found
*/
export function getWithLruTracking<T>(
key: string,
updateAccessTime: boolean = true
): T | null {
const raw = sessionStorage.getItem(key)
if (!raw) return null
try {
const entry = unwrapFromStorage<T>(raw)
if (updateAccessTime && entry.accessedAt !== Date.now()) {
try {
sessionStorage.setItem(key, wrapForStorage(entry.data))
} catch {
// Ignore quota errors when updating access time
}
}
return entry.data
} catch (error) {
console.warn(`[SessionStorageLRU] Failed to parse ${key}:`, error)
return null
}
}
/**
* Removes an item from session storage
*/
export function removeFromStorage(key: string): void {
sessionStorage.removeItem(key)
}
/**
* Gets storage statistics for debugging
*/
export function getStorageStats(keyPattern?: RegExp): {
totalItems: number
matchingItems: number
totalSizeKB: number
matchingSizeKB: number
entries: Array<{ key: string; sizeKB: number; accessedAt: number }>
} {
let totalSize = 0
let matchingSize = 0
let matchingItems = 0
const entries: Array<{ key: string; sizeKB: number; accessedAt: number }> = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (!key) continue
const raw = sessionStorage.getItem(key)
if (!raw) continue
const size = raw.length * 2
totalSize += size
if (!keyPattern || keyPattern.test(key)) {
matchingItems++
matchingSize += size
try {
const entry = unwrapFromStorage(raw)
entries.push({
key,
sizeKB: Math.round(size / 1024),
accessedAt: entry.accessedAt
})
} catch {
entries.push({
key,
sizeKB: Math.round(size / 1024),
accessedAt: 0
})
}
}
}
return {
totalItems: sessionStorage.length,
matchingItems,
totalSizeKB: Math.round(totalSize / 1024),
matchingSizeKB: Math.round(matchingSize / 1024),
entries: entries.sort((a, b) => a.accessedAt - b.accessedAt)
}
}

View File

@@ -1,13 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getStorageStats,
getWithLruTracking,
getWithAccessTracking,
removeFromStorage,
setWithLruEviction
} from './sessionStorageLruService'
setWithEviction
} from './workflowSessionStorageService'
describe('sessionStorageLruService', () => {
describe('workflowSessionStorageService', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
@@ -17,29 +16,21 @@ describe('sessionStorageLruService', () => {
vi.restoreAllMocks()
})
describe('setWithLruEviction', () => {
it('stores data with LRU metadata wrapper', () => {
describe('setWithEviction', () => {
it('stores data with accessedAt wrapper', () => {
const data = { nodes: [], links: [] }
const result = setWithLruEviction('workflow:test', data)
const result = setWithEviction('workflow:test', data)
expect(result).toBe(true)
const stored = sessionStorage.getItem('workflow:test')
expect(stored).toBeTruthy()
const parsed = JSON.parse(stored!)
expect(parsed).toHaveProperty('accessedAt')
expect(parsed).toHaveProperty('data')
expect(parsed.data).toEqual(data)
expect(typeof parsed.accessedAt).toBe('number')
})
it('returns true on successful storage', () => {
const result = setWithLruEviction('key', { test: 'data' })
expect(result).toBe(true)
})
it('evicts LRU entries when quota is exceeded', () => {
it('evicts oldest entries when quota is exceeded', () => {
const oldEntry = { accessedAt: 1000, data: { old: 'data' } }
const newEntry = { accessedAt: 2000, data: { new: 'data' } }
@@ -51,13 +42,12 @@ describe('sessionStorageLruService', () => {
vi.spyOn(sessionStorage, 'setItem').mockImplementation((key, value) => {
callCount++
if (key === 'workflow:current' && callCount === 1) {
const error = new DOMException('Quota exceeded', 'QuotaExceededError')
throw error
throw new DOMException('Quota exceeded', 'QuotaExceededError')
}
return originalSetItem(key, value)
})
const result = setWithLruEviction(
const result = setWithEviction(
'workflow:current',
{ current: 'data' },
/^workflow:/
@@ -73,32 +63,42 @@ describe('sessionStorageLruService', () => {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
})
const result = setWithLruEviction('key', { test: 'data' })
const result = setWithEviction('key', { test: 'data' })
expect(result).toBe(false)
spy.mockRestore()
})
it('returns false on unexpected errors', () => {
const spy = vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => {
throw new Error('Unexpected error')
})
const result = setWithEviction('key', { test: 'data' })
expect(result).toBe(false)
spy.mockRestore()
})
})
describe('getWithLruTracking', () => {
describe('getWithAccessTracking', () => {
it('returns null for non-existent key', () => {
const result = getWithLruTracking('nonexistent')
expect(result).toBeNull()
expect(getWithAccessTracking('nonexistent')).toBeNull()
})
it('returns unwrapped data for new format entries', () => {
const entry = { accessedAt: Date.now(), data: { nodes: [], links: [] } }
sessionStorage.setItem('workflow:test', JSON.stringify(entry))
const result = getWithLruTracking('workflow:test')
const result = getWithAccessTracking('workflow:test')
expect(result).toEqual({ nodes: [], links: [] })
})
it('handles legacy format (unwrapped data) with accessedAt: 0', () => {
it('handles legacy format with accessedAt: 0', () => {
const legacyData = { nodes: [1, 2, 3], links: [] }
sessionStorage.setItem('workflow:legacy', JSON.stringify(legacyData))
const result = getWithLruTracking('workflow:legacy')
const result = getWithAccessTracking('workflow:legacy')
expect(result).toEqual(legacyData)
})
@@ -107,7 +107,7 @@ describe('sessionStorageLruService', () => {
const entry = { accessedAt: oldTime, data: { test: 'data' } }
sessionStorage.setItem('workflow:test', JSON.stringify(entry))
getWithLruTracking('workflow:test', true)
getWithAccessTracking('workflow:test', true)
const stored = JSON.parse(sessionStorage.getItem('workflow:test')!)
expect(stored.accessedAt).toBeGreaterThan(oldTime)
@@ -118,11 +118,18 @@ describe('sessionStorageLruService', () => {
const entry = { accessedAt: oldTime, data: { test: 'data' } }
sessionStorage.setItem('workflow:test', JSON.stringify(entry))
getWithLruTracking('workflow:test', false)
getWithAccessTracking('workflow:test', false)
const stored = JSON.parse(sessionStorage.getItem('workflow:test')!)
expect(stored.accessedAt).toBe(oldTime)
})
it('returns null for invalid JSON', () => {
sessionStorage.setItem('workflow:invalid', 'not json')
const result = getWithAccessTracking('workflow:invalid')
expect(result).toBeNull()
})
})
describe('removeFromStorage', () => {
@@ -135,61 +142,8 @@ describe('sessionStorageLruService', () => {
})
})
describe('getStorageStats', () => {
it('returns stats for all entries', () => {
const entry1 = { accessedAt: 1000, data: { a: 1 } }
const entry2 = { accessedAt: 2000, data: { b: 2 } }
sessionStorage.setItem('workflow:a', JSON.stringify(entry1))
sessionStorage.setItem('workflow:b', JSON.stringify(entry2))
sessionStorage.setItem('other:c', 'value')
const stats = getStorageStats()
expect(stats.totalItems).toBe(3)
expect(stats.matchingItems).toBe(3)
})
it('filters entries by pattern', () => {
const entry1 = { accessedAt: 1000, data: { a: 1 } }
const entry2 = { accessedAt: 2000, data: { b: 2 } }
sessionStorage.setItem('workflow:a', JSON.stringify(entry1))
sessionStorage.setItem('workflow:b', JSON.stringify(entry2))
sessionStorage.setItem('other:c', 'value')
const stats = getStorageStats(/^workflow:/)
expect(stats.totalItems).toBe(3)
expect(stats.matchingItems).toBe(2)
expect(stats.entries).toHaveLength(2)
})
it('sorts entries by accessedAt (oldest first)', () => {
const entry1 = { accessedAt: 2000, data: { newer: true } }
const entry2 = { accessedAt: 1000, data: { older: true } }
sessionStorage.setItem('workflow:newer', JSON.stringify(entry1))
sessionStorage.setItem('workflow:older', JSON.stringify(entry2))
const stats = getStorageStats(/^workflow:/)
expect(stats.entries[0].key).toBe('workflow:older')
expect(stats.entries[1].key).toBe('workflow:newer')
})
it('treats legacy entries as accessedAt: 0', () => {
const legacyData = { nodes: [], links: [] }
const newEntry = { accessedAt: 1000, data: { test: true } }
sessionStorage.setItem('workflow:legacy', JSON.stringify(legacyData))
sessionStorage.setItem('workflow:new', JSON.stringify(newEntry))
const stats = getStorageStats(/^workflow:/)
expect(stats.entries[0].key).toBe('workflow:legacy')
expect(stats.entries[0].accessedAt).toBe(0)
})
})
describe('LRU eviction order', () => {
it('evicts oldest entries first (legacy before new)', () => {
describe('eviction order', () => {
it('evicts legacy entries (accessedAt: 0) before new entries', () => {
const legacyData = { nodes: [], links: [] }
const newEntry = { accessedAt: Date.now(), data: { new: true } }
@@ -206,18 +160,21 @@ describe('sessionStorageLruService', () => {
return originalSetItem(key, value)
})
setWithLruEviction('workflow:current', { current: true }, /^workflow:/)
setWithEviction('workflow:current', { current: true }, /^workflow:/)
expect(sessionStorage.getItem('workflow:legacy')).toBeNull()
expect(sessionStorage.getItem('workflow:new')).toBeTruthy()
})
it('evicts oldest new-format entries when no legacy entries exist', () => {
const oldEntry = { accessedAt: 1000, data: { old: true } }
const newEntry = { accessedAt: 2000, data: { new: true } }
it('does not evict protected keys', () => {
const workspaceEntry = { accessedAt: 0, data: { workspace: true } }
const workflowEntry = { accessedAt: 1000, data: { workflow: true } }
sessionStorage.setItem('workflow:old', JSON.stringify(oldEntry))
sessionStorage.setItem('workflow:new', JSON.stringify(newEntry))
sessionStorage.setItem(
'workspace.settings',
JSON.stringify(workspaceEntry)
)
sessionStorage.setItem('workflow:old', JSON.stringify(workflowEntry))
let callCount = 0
const originalSetItem = sessionStorage.setItem.bind(sessionStorage)
@@ -229,10 +186,10 @@ describe('sessionStorageLruService', () => {
return originalSetItem(key, value)
})
setWithLruEviction('workflow:current', { current: true }, /^workflow:/)
setWithEviction('workflow:current', { current: true }, /^workflow:/)
expect(sessionStorage.getItem('workspace.settings')).toBeTruthy()
expect(sessionStorage.getItem('workflow:old')).toBeNull()
expect(sessionStorage.getItem('workflow:new')).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,158 @@
/**
* Session storage service for workflow data with automatic eviction on quota exceeded.
* Uses timestamp-based access tracking to evict least recently used entries.
*/
interface StorageEntry<T> {
accessedAt: number
data: T
}
const MAX_EVICTION_ATTEMPTS = 3
const PROTECTED_KEY_PREFIXES = ['workspace.', 'Workspace.']
function isProtectedKey(key: string): boolean {
return PROTECTED_KEY_PREFIXES.some((prefix) => key.includes(prefix))
}
/**
* Legacy format: raw workflow JSON without wrapper.
* @deprecated Remove after 2026-07-15
*/
function isLegacyFormat(parsed: unknown): boolean {
if (typeof parsed !== 'object' || parsed === null) return true
const obj = parsed as Record<string, unknown>
return !('accessedAt' in obj && 'data' in obj)
}
function wrapForStorage<T>(data: T): string {
return JSON.stringify({ accessedAt: Date.now(), data })
}
/**
* @deprecated Legacy handling can be removed after 2026-07-15
*/
function unwrapFromStorage<T>(raw: string): StorageEntry<T> {
const parsed = JSON.parse(raw)
if (isLegacyFormat(parsed)) {
return { accessedAt: 0, data: parsed as T }
}
return parsed as StorageEntry<T>
}
function getEvictableKeys(keyPattern: RegExp, excludeKey?: string): string[] {
const entries: Array<{ key: string; accessedAt: number }> = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (
!key ||
!keyPattern.test(key) ||
isProtectedKey(key) ||
key === excludeKey
)
continue
const raw = sessionStorage.getItem(key)
if (!raw) continue
try {
const { accessedAt } = unwrapFromStorage(raw)
entries.push({ key, accessedAt })
} catch {
entries.push({ key, accessedAt: 0 })
}
}
return entries.sort((a, b) => a.accessedAt - b.accessedAt).map((e) => e.key)
}
function evictOldestEntries(
keyPattern: RegExp,
excludeKey: string,
count: number
): number {
const keys = getEvictableKeys(keyPattern, excludeKey)
const toEvict = keys.slice(0, count)
for (const key of toEvict) {
sessionStorage.removeItem(key)
}
return toEvict.length
}
/**
* Stores data in session storage with automatic eviction on quota exceeded.
* @returns true if stored successfully, false if storage failed after retries
*/
export function setWithEviction<T>(
key: string,
data: T,
evictionPattern: RegExp = /^workflow:/
): boolean {
const wrapped = wrapForStorage(data)
for (let attempt = 0; attempt <= MAX_EVICTION_ATTEMPTS; attempt++) {
try {
sessionStorage.setItem(key, wrapped)
return true
} catch (error) {
if (
!(error instanceof DOMException) ||
error.name !== 'QuotaExceededError'
) {
console.error('[WorkflowStorage] Unexpected storage error')
return false
}
if (attempt === MAX_EVICTION_ATTEMPTS) {
console.warn(
'[WorkflowStorage] Storage full after max eviction attempts'
)
return false
}
const evicted = evictOldestEntries(evictionPattern, key, attempt + 1)
if (evicted === 0) {
console.warn('[WorkflowStorage] No entries available for eviction')
return false
}
}
}
return false
}
/**
* Gets data from session storage, updating access time for eviction tracking.
* @returns The stored data, or null if not found
*/
export function getWithAccessTracking<T>(
key: string,
updateAccessTime = true
): T | null {
const raw = sessionStorage.getItem(key)
if (!raw) return null
try {
const entry = unwrapFromStorage<T>(raw)
if (updateAccessTime) {
try {
sessionStorage.setItem(key, wrapForStorage(entry.data))
} catch {
// Ignore quota errors when updating access time
}
}
return entry.data
} catch {
console.warn('[WorkflowStorage] Failed to parse entry:', key)
return null
}
}
export function removeFromStorage(key: string): void {
sessionStorage.removeItem(key)
}