feat(preferences): implement optimistic and pessimistic update strategies in PreferenceService

- Introduced `PreferenceUpdateOptions` interface to configure update behavior.
- Enhanced `set` and `setMultiple` methods in `PreferenceService` to support both optimistic and pessimistic update strategies, allowing for immediate UI feedback or database-first updates.
- Updated `usePreference` and `useMultiplePreferences` hooks to accept options for flexible update strategies.
- Improved handling of concurrent updates with request queues and rollback mechanisms for optimistic updates.
- Enhanced documentation in hooks to clarify usage of update strategies.
This commit is contained in:
fullex 2025-08-15 16:48:22 +08:00
parent e15005d1cf
commit 9bde833419
4 changed files with 351 additions and 28 deletions

View File

@ -9,3 +9,7 @@ export type PreferenceShortcutType = {
enabled: boolean
system: boolean
}
export interface PreferenceUpdateOptions {
optimistic: boolean
}

View File

@ -1,9 +1,8 @@
import { loggerService } from '@logger'
import { DefaultPreferences } from '@shared/data/preferences'
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
import type { PreferenceDefaultScopeType, PreferenceKeyType, PreferenceUpdateOptions } from '@shared/data/types'
const logger = loggerService.withContext('PreferenceService')
/**
* Renderer-side PreferenceService providing cached access to preferences
* with real-time synchronization across windows using useSyncExternalStore
@ -21,6 +20,29 @@ export class PreferenceService {
private fullCacheLoaded = false
// Optimistic update tracking
private optimisticValues = new Map<
PreferenceKeyType,
{
value: any
originalValue: any
timestamp: number
requestId: string
isFirst: boolean
}
>()
// Request queues for managing concurrent updates to the same key
private requestQueues = new Map<
PreferenceKeyType,
Array<{
requestId: string
value: any
resolve: (value: void | PromiseLike<void>) => void
reject: (reason?: any) => void
}>
>()
private constructor() {
this.setupChangeListeners()
}
@ -97,19 +119,85 @@ export class PreferenceService {
}
/**
* Set a single preference value
* Set a single preference value with configurable update strategy
*/
public async set<K extends PreferenceKeyType>(key: K, value: PreferenceDefaultScopeType[K]): Promise<void> {
public async set<K extends PreferenceKeyType>(
key: K,
value: PreferenceDefaultScopeType[K],
options: PreferenceUpdateOptions = { optimistic: true }
): Promise<void> {
if (options.optimistic) {
return this.setOptimistic(key, value)
} else {
return this.setPessimistic(key, value)
}
}
/**
* Optimistic update: Queue request to prevent race conditions
*/
private async setOptimistic<K extends PreferenceKeyType>(
key: K,
value: PreferenceDefaultScopeType[K]
): Promise<void> {
const requestId = this.generateRequestId()
return this.enqueueRequest(key, requestId, value)
}
/**
* Execute optimistic update with proper race condition handling
*/
private async executeOptimisticUpdate(key: PreferenceKeyType, value: any, requestId: string): Promise<void> {
const existingState = this.optimisticValues.get(key)
const isFirst = !existingState
const originalValue = isFirst ? this.cache[key] : existingState.originalValue
// Update cache immediately for responsive UI
this.cache[key] = value
this.notifyChangeListeners(key)
// Track optimistic state with proper original value protection
this.optimisticValues.set(key, {
value,
originalValue, // Use real original value (from first request) or current if first
timestamp: Date.now(),
requestId,
isFirst
})
logger.debug(`Optimistic update for ${key} (${requestId})${isFirst ? ' [FIRST]' : ''}`)
// Attempt to persist to main process
try {
await window.api.preference.set(key, value)
// Success: confirm optimistic update
this.confirmOptimistic(key, requestId)
logger.debug(`Optimistic update for ${key} (${requestId}) confirmed`)
} catch (error) {
// Failure: rollback optimistic update
this.rollbackOptimistic(key, requestId)
logger.error(`Optimistic update failed for ${key} (${requestId}), rolling back:`, error as Error)
throw error
}
}
/**
* Pessimistic update: Wait for database confirmation before updating UI
*/
private async setPessimistic<K extends PreferenceKeyType>(
key: K,
value: PreferenceDefaultScopeType[K]
): Promise<void> {
try {
await window.api.preference.set(key, value)
// Update local cache immediately for responsive UI
// Update local cache after successful database update
this.cache[key] = value
this.notifyChangeListeners(key)
logger.debug(`Preference ${key} set to:`, { value })
logger.debug(`Pessimistic update for ${key} completed`)
} catch (error) {
logger.error(`Failed to set preference ${key}:`, error as Error)
logger.error(`Pessimistic update failed for ${key}:`, error as Error)
throw error
}
}
@ -166,21 +254,90 @@ export class PreferenceService {
return cachedResults
}
/**
* Set multiple preferences at once
* Set multiple preferences at once with configurable update strategy
*/
public async setMultiple(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
public async setMultiple(
updates: Partial<PreferenceDefaultScopeType>,
options: PreferenceUpdateOptions = { optimistic: true }
): Promise<void> {
if (options.optimistic) {
return this.setMultipleOptimistic(updates)
} else {
return this.setMultiplePessimistic(updates)
}
}
/**
* Optimistic batch update: Update UI immediately, then sync to database
*/
private async setMultipleOptimistic(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
const batchRequestId = this.generateRequestId()
const originalValues: Record<string, any> = {}
const keysToUpdate = Object.keys(updates) as PreferenceKeyType[]
// For batch updates, we need to check for existing optimistic states
// and preserve the original values from first requests
for (const key of keysToUpdate) {
const existingState = this.optimisticValues.get(key)
originalValues[key] = existingState ? existingState.originalValue : this.cache[key as PreferenceKeyType]
}
// Update cache immediately and track original values
for (const [key, value] of Object.entries(updates)) {
this.cache[key as PreferenceKeyType] = value
this.notifyChangeListeners(key)
}
// Track optimistic states for all keys with proper original value protection
const timestamp = Date.now()
keysToUpdate.forEach((key) => {
const existingState = this.optimisticValues.get(key)
const isFirst = !existingState
this.optimisticValues.set(key, {
value: updates[key as PreferenceKeyType],
originalValue: originalValues[key], // Use protected original value
timestamp,
requestId: `${batchRequestId}_${key}`, // Unique ID per key in batch
isFirst
})
})
logger.debug(`Optimistic batch update for ${keysToUpdate.length} preferences (${batchRequestId})`)
// Attempt to persist to main process
try {
await window.api.preference.setMultiple(updates)
// Success: confirm all optimistic updates
keysToUpdate.forEach((key) => this.confirmOptimistic(key, `${batchRequestId}_${key}`))
logger.debug(`Optimistic batch update confirmed for ${keysToUpdate.length} preferences (${batchRequestId})`)
} catch (error) {
// Failure: rollback all optimistic updates
keysToUpdate.forEach((key) => this.rollbackOptimistic(key, `${batchRequestId}_${key}`))
logger.error(
`Optimistic batch update failed, rolling back ${keysToUpdate.length} preferences (${batchRequestId}):`,
error as Error
)
throw error
}
}
/**
* Pessimistic batch update: Wait for database confirmation before updating UI
*/
private async setMultiplePessimistic(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
try {
await window.api.preference.setMultiple(updates)
// Update local cache for all updated values
// Update local cache for all updated values after successful database update
for (const [key, value] of Object.entries(updates)) {
this.cache[key as PreferenceKeyType] = value
this.notifyChangeListeners(key)
}
logger.debug(`Updated ${Object.keys(updates).length} preferences`)
logger.debug(`Pessimistic batch update completed for ${Object.keys(updates).length} preferences`)
} catch (error) {
logger.error('Failed to set multiple preferences:', error as Error)
logger.error(`Pessimistic batch update failed:`, error as Error)
throw error
}
}
@ -299,6 +456,132 @@ export class PreferenceService {
}
}
/**
* Confirm an optimistic update (called when main process confirms the update)
*/
private confirmOptimistic(key: PreferenceKeyType, requestId: string): void {
const optimisticState = this.optimisticValues.get(key)
if (optimisticState && optimisticState.requestId === requestId) {
this.optimisticValues.delete(key)
logger.debug(`Optimistic update confirmed for ${key} (${requestId})`)
// Process next queued request
this.completeQueuedRequest(key)
} else {
logger.warn(
`Attempted to confirm mismatched request for ${key}: expected ${optimisticState?.requestId}, got ${requestId}`
)
}
}
/**
* Rollback an optimistic update (called on failure)
*/
private rollbackOptimistic(key: PreferenceKeyType, requestId: string): void {
const optimisticState = this.optimisticValues.get(key)
if (optimisticState && optimisticState.requestId === requestId) {
// Restore original value (the real original value from first request)
this.cache[key] = optimisticState.originalValue
this.notifyChangeListeners(key)
// Clear optimistic state
this.optimisticValues.delete(key)
const duration = Date.now() - optimisticState.timestamp
logger.warn(`Optimistic update rolled back for ${key} (${requestId}) after ${duration}ms to original value`)
// Process next queued request
this.completeQueuedRequest(key)
} else {
logger.warn(
`Attempted to rollback mismatched request for ${key}: expected ${optimisticState?.requestId}, got ${requestId}`
)
}
}
/**
* Get all pending optimistic updates (for debugging)
*/
public getPendingOptimisticUpdates(): Array<{
key: string
value: any
originalValue: any
timestamp: number
requestId: string
isFirst: boolean
}> {
return Array.from(this.optimisticValues.entries()).map(([key, state]) => ({
key,
value: state.value,
originalValue: state.originalValue,
timestamp: state.timestamp,
requestId: state.requestId,
isFirst: state.isFirst
}))
}
/**
* Generate unique request ID for tracking concurrent requests
*/
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
}
/**
* Add request to queue for a specific key
*/
private enqueueRequest(key: PreferenceKeyType, requestId: string, value: any): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.requestQueues.has(key)) {
this.requestQueues.set(key, [])
}
const queue = this.requestQueues.get(key)!
queue.push({ requestId, value, resolve, reject })
// If this is the first request in queue, process it immediately
if (queue.length === 1) {
this.processNextQueuedRequest(key)
}
})
}
/**
* Process the next queued request for a key
*/
private async processNextQueuedRequest(key: PreferenceKeyType): Promise<void> {
const queue = this.requestQueues.get(key)
if (!queue || queue.length === 0) {
return
}
const currentRequest = queue[0]
try {
await this.executeOptimisticUpdate(key, currentRequest.value, currentRequest.requestId)
currentRequest.resolve()
} catch (error) {
currentRequest.reject(error)
}
}
/**
* Complete current request and process next in queue
*/
private completeQueuedRequest(key: PreferenceKeyType): void {
const queue = this.requestQueues.get(key)
if (queue && queue.length > 0) {
queue.shift() // Remove completed request
// Process next request if any
if (queue.length > 0) {
this.processNextQueuedRequest(key)
} else {
// Clean up empty queue
this.requestQueues.delete(key)
}
}
}
/**
* Clear all cached preferences (for testing/debugging)
*/
@ -316,6 +599,11 @@ export class PreferenceService {
this.changeListenerCleanup()
this.changeListenerCleanup = null
}
// Clear all optimistic states and request queues
this.optimisticValues.clear()
this.requestQueues.clear()
this.clearCache()
this.allChangesListeners.clear()
this.keyChangeListeners.clear()

View File

@ -1,6 +1,6 @@
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
import type { PreferenceDefaultScopeType, PreferenceKeyType, PreferenceUpdateOptions } from '@shared/data/types'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
const logger = loggerService.withContext('usePreference')
@ -8,17 +8,28 @@ const logger = loggerService.withContext('usePreference')
/**
* React hook for managing a single preference value with automatic synchronization
* Uses useSyncExternalStore for optimal React 18 integration and real-time updates
* Supports both optimistic and pessimistic update strategies for flexible UX
*
* @param key - The preference key to manage (must be a valid PreferenceKeyType)
* @param options - Optional configuration for update behavior:
* - strategy: 'optimistic' (default) for immediate UI updates, 'pessimistic' for database-first updates
* @returns A tuple [value, setValue] where:
* - value: Current preference value or undefined if not loaded/cached
* - setValue: Async function to update the preference value
*
* @example
* ```typescript
* // Basic usage - managing theme preference
* // Basic usage - managing theme preference with optimistic updates (default)
* const [theme, setTheme] = usePreference('app.theme.mode')
*
* // Pessimistic updates for critical settings
* const [apiKey, setApiKey] = usePreference('api.key', { strategy: 'pessimistic' })
*
* // Simple optimistic updates
* const [fontSize, setFontSize] = usePreference('chat.message.font_size', {
* strategy: 'optimistic'
* })
*
* // Conditional rendering based on preference value
* if (theme === undefined) {
* return <LoadingSpinner />
@ -27,9 +38,9 @@ const logger = loggerService.withContext('usePreference')
* // Updating preference value
* const handleThemeChange = async (newTheme: string) => {
* try {
* await setTheme(newTheme)
* await setTheme(newTheme) // UI updates immediately with optimistic strategy
* } catch (error) {
* console.error('Failed to update theme:', error)
* console.error('Failed to update theme:', error) // Will auto-rollback on failure
* }
* }
*
@ -45,13 +56,15 @@ const logger = loggerService.withContext('usePreference')
* @example
* ```typescript
* // Advanced usage with form handling for message font size
* const [fontSize, setFontSize] = usePreference('chat.message.font_size')
* const [fontSize, setFontSize] = usePreference('chat.message.font_size', {
* strategy: 'optimistic' // Immediate feedback for UI preferences
* })
*
* const handleFontSizeChange = useCallback(async (size: number) => {
* if (size < 8 || size > 72) {
* throw new Error('Font size must be between 8 and 72')
* }
* await setFontSize(size)
* await setFontSize(size) // Immediate UI update, syncs to database
* }, [setFontSize])
*
* return (
@ -66,7 +79,8 @@ const logger = loggerService.withContext('usePreference')
* ```
*/
export function usePreference<K extends PreferenceKeyType>(
key: K
key: K,
options: PreferenceUpdateOptions = { optimistic: true }
): [PreferenceDefaultScopeType[K] | undefined, (value: PreferenceDefaultScopeType[K]) => Promise<void>] {
// Subscribe to changes for this specific preference
const value = useSyncExternalStore(
@ -88,13 +102,13 @@ export function usePreference<K extends PreferenceKeyType>(
const setValue = useCallback(
async (newValue: PreferenceDefaultScopeType[K]) => {
try {
await preferenceService.set(key, newValue)
await preferenceService.set(key, newValue, options)
} catch (error) {
logger.error(`Failed to set preference ${key}:`, error as Error)
throw error
}
},
[key]
[key, options]
)
return [value, setValue]
@ -103,22 +117,31 @@ export function usePreference<K extends PreferenceKeyType>(
/**
* React hook for managing multiple preference values with efficient batch operations
* Automatically synchronizes all specified preferences and provides type-safe access
* Supports both optimistic and pessimistic update strategies for flexible UX
*
* @param keys - Object mapping local names to preference keys. Keys are your custom names,
* values must be valid PreferenceKeyType identifiers
* @param options - Optional configuration for update behavior:
* - strategy: 'optimistic' (default) for immediate UI updates, 'pessimistic' for database-first updates
* @returns A tuple [values, updateValues] where:
* - values: Object with your local keys mapped to current preference values (undefined if not loaded)
* - updateValues: Async function to batch update multiple preferences at once
*
* @example
* ```typescript
* // Basic usage - managing related UI preferences
* // Basic usage - managing related UI preferences with optimistic updates
* const [uiSettings, setUISettings] = useMultiplePreferences({
* theme: 'app.theme.mode',
* fontSize: 'chat.message.font_size',
* showLineNumbers: 'chat.code.show_line_numbers'
* })
*
* // Pessimistic updates for critical settings
* const [apiSettings, setApiSettings] = useMultiplePreferences({
* apiKey: 'api.key',
* endpoint: 'api.endpoint'
* }, { strategy: 'pessimistic' })
*
* // Accessing individual values with type safety
* const currentTheme = uiSettings.theme // string | undefined
* const currentFontSize = uiSettings.fontSize // number | undefined
@ -221,7 +244,8 @@ export function usePreference<K extends PreferenceKeyType>(
* ```
*/
export function useMultiplePreferences<T extends Record<string, PreferenceKeyType>>(
keys: T
keys: T,
options: PreferenceUpdateOptions = { optimistic: true }
): [
{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] | undefined },
(updates: Partial<{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] }>) => Promise<void>
@ -294,13 +318,13 @@ export function useMultiplePreferences<T extends Record<string, PreferenceKeyTyp
}
}
await preferenceService.setMultiple(prefUpdates)
await preferenceService.setMultiple(prefUpdates, options)
} catch (error) {
logger.error('Failed to update preferences:', error as Error)
throw error
}
},
[keys]
[keys, options]
)
// Type-cast the values to the expected shape

View File

@ -220,7 +220,11 @@ const PreferenceMultipleTests: React.FC = () => {
{/* Zoom Factor Slider */}
<div>
<Text strong>
: {Math.round((typeof (values as any).zoomFactor === 'number' ? (values as any).zoomFactor : 1.0) * 100)}%
:{' '}
{Math.round(
(typeof (values as any).zoomFactor === 'number' ? (values as any).zoomFactor : 1.0) * 100
)}
%
</Text>
<Slider
min={0.5}
@ -240,7 +244,9 @@ const PreferenceMultipleTests: React.FC = () => {
{/* Font Size Slider */}
<div>
<Text strong>: {typeof (values as any).fontSize === 'number' ? (values as any).fontSize : 14}px</Text>
<Text strong>
: {typeof (values as any).fontSize === 'number' ? (values as any).fontSize : 14}px
</Text>
<Slider
min={8}
max={72}
@ -262,7 +268,8 @@ const PreferenceMultipleTests: React.FC = () => {
{/* Selection Window Opacity Slider */}
<div>
<Text strong>
: {Math.round(typeof (values as any).opacity === 'number' ? (values as any).opacity : 100)}%
:{' '}
{Math.round(typeof (values as any).opacity === 'number' ? (values as any).opacity : 100)}%
</Text>
<Slider
min={10}