1 parent
99dcb6e43b
commit
301cb5abce
48 files changed
+5444
-1015
No files matched your search
@@ -10,58 +10,761 @@ type PreloadImagePayload = {
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
allowFilesystemScan?: boolean
|
||||
emitResolved?: boolean
|
||||
scope?: string
|
||||
priority?: 'high' | 'normal' | 'low'
|
||||
}
|
||||
|
||||
type PreloadEnqueueResult = {
|
||||
success: true
|
||||
requested: number
|
||||
accepted: number
|
||||
mergedQueued: number
|
||||
skippedActive: number
|
||||
skippedPending: number
|
||||
ignoredCanceled: number
|
||||
rejectedCapacity: number
|
||||
deferred: number
|
||||
handledIdentities: string[]
|
||||
acceptedIdentities: string[]
|
||||
mergedQueuedIdentities: string[]
|
||||
skippedActiveIdentities: string[]
|
||||
skippedPendingIdentities: string[]
|
||||
rejectedIdentities: string[]
|
||||
deferredIdentities: string[]
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
identity: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
allowFilesystemScan: boolean
|
||||
scopes: Set<string>
|
||||
emitResolvedScopes: Set<string>
|
||||
priority: 'high' | 'normal' | 'low'
|
||||
}
|
||||
|
||||
type PreloadHighWaterStats = {
|
||||
queued: number
|
||||
pending: number
|
||||
queuedCache: number
|
||||
queuedDecrypt: number
|
||||
queuedHigh: number
|
||||
queuedNormal: number
|
||||
queuedLow: number
|
||||
activeCache: number
|
||||
activeDecrypt: number
|
||||
activeIdentities: number
|
||||
}
|
||||
|
||||
const isLikelyThumbnailCachePath = (localPath?: string): boolean => {
|
||||
const normalized = String(localPath || '').trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return (
|
||||
normalized.includes('_thumb') ||
|
||||
normalized.includes('.thumb') ||
|
||||
normalized.includes('_t.') ||
|
||||
normalized.includes('.t.')
|
||||
)
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
private queue: PreloadTask[] = []
|
||||
private pending = new Set<string>()
|
||||
private queuedCacheByIdentity = new Map<string, PreloadTask>()
|
||||
private queuedDecryptByIdentity = new Map<string, PreloadTask>()
|
||||
private activeByIdentity = new Map<string, PreloadTask>()
|
||||
private canceledScopes = new Map<string, number>()
|
||||
private totals = {
|
||||
accepted: 0,
|
||||
mergedQueued: 0,
|
||||
skippedActive: 0,
|
||||
ignoredCanceled: 0,
|
||||
droppedQueued: 0,
|
||||
canceledQueued: 0,
|
||||
canceledActive: 0,
|
||||
promotedActive: 0,
|
||||
rejectedCapacity: 0,
|
||||
deferredLowPriority: 0,
|
||||
lowPriorityIdleDeferrals: 0,
|
||||
skippedPending: 0,
|
||||
forcedThumbnailRefreshes: 0,
|
||||
forcedThumbnailRefreshFailures: 0,
|
||||
lowPriorityRejected: 0,
|
||||
activeCacheSnapshots: 0,
|
||||
activeCacheSnapshotSkipped: 0,
|
||||
activeCacheSnapshotCanceled: 0,
|
||||
started: 0,
|
||||
completed: 0
|
||||
}
|
||||
private activeCache = 0
|
||||
private activeDecrypt = 0
|
||||
private readonly maxCacheConcurrent = 8
|
||||
private queuedPriorityCounts: Record<PreloadTask['priority'], number> = {
|
||||
high: 0,
|
||||
normal: 0,
|
||||
low: 0
|
||||
}
|
||||
private activeCacheSnapshotPending = new Set<string>()
|
||||
private activeCacheSnapshotLastAt = new Map<string, number>()
|
||||
private lastForegroundActivityAt = Date.now()
|
||||
private lowPriorityWakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private highWater: PreloadHighWaterStats = {
|
||||
queued: 0,
|
||||
pending: 0,
|
||||
queuedCache: 0,
|
||||
queuedDecrypt: 0,
|
||||
queuedHigh: 0,
|
||||
queuedNormal: 0,
|
||||
queuedLow: 0,
|
||||
activeCache: 0,
|
||||
activeDecrypt: 0,
|
||||
activeIdentities: 0
|
||||
}
|
||||
private readonly maxCacheConcurrent = 4
|
||||
private readonly maxDecryptConcurrent = 2
|
||||
private readonly maxQueueSize = 320
|
||||
private readonly maxLowPriorityQueueSize = 160
|
||||
private readonly lowPrioritySoftQueueSize = 240
|
||||
private readonly canceledScopeTtlMs = 2 * 60 * 1000
|
||||
private readonly activeCacheSnapshotCooldownMs = 1800
|
||||
private readonly lowPriorityIdleDelayMs = 1500
|
||||
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): PreloadEnqueueResult {
|
||||
const result = this.createEnqueueResult(Array.isArray(payloads) ? payloads.length : 0)
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return result
|
||||
this.pruneCanceledScopes()
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
if (!cacheKey) continue
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
const allowFilesystemScan = options?.allowFilesystemScan !== false
|
||||
const emitResolved = options?.emitResolved === true
|
||||
const scope = String(options?.scope || 'global').trim() || 'global'
|
||||
const priority = options?.priority || (allowDecrypt ? 'low' : 'normal')
|
||||
this.noteForegroundActivity(allowDecrypt, priority)
|
||||
if (this.isScopeCanceled(scope)) {
|
||||
this.totals.ignoredCanceled += payloads.length
|
||||
result.ignoredCanceled += payloads.length
|
||||
return result
|
||||
}
|
||||
for (const payload of payloads) {
|
||||
const identity = this.getTaskIdentity(payload)
|
||||
if (!identity) continue
|
||||
const activeTask = this.activeByIdentity.get(identity)
|
||||
if (activeTask && (!allowDecrypt || activeTask.allowDecrypt)) {
|
||||
activeTask.scopes.add(scope)
|
||||
activeTask.allowCacheIndex = activeTask.allowCacheIndex || allowCacheIndex
|
||||
activeTask.allowFilesystemScan = activeTask.allowFilesystemScan || allowFilesystemScan
|
||||
if (this.priorityScore(priority) < this.priorityScore(activeTask.priority)) {
|
||||
activeTask.priority = priority
|
||||
this.totals.promotedActive += 1
|
||||
}
|
||||
if (emitResolved) {
|
||||
activeTask.emitResolvedScopes.add(scope)
|
||||
}
|
||||
if (!allowDecrypt && activeTask.allowDecrypt && emitResolved) {
|
||||
this.emitActiveCacheSnapshot(payload, identity, allowCacheIndex, scope)
|
||||
}
|
||||
this.totals.skippedActive += 1
|
||||
result.skippedActive += 1
|
||||
result.handledIdentities.push(identity)
|
||||
result.skippedActiveIdentities.push(identity)
|
||||
continue
|
||||
}
|
||||
|
||||
const mode = allowDecrypt ? 'decrypt' : 'cache'
|
||||
const key = `${mode}|${identity}`
|
||||
const queuedMap = this.getQueuedMap(allowDecrypt)
|
||||
const queuedTask = queuedMap.get(identity)
|
||||
if (queuedTask) {
|
||||
this.totals.mergedQueued += 1
|
||||
result.mergedQueued += 1
|
||||
result.handledIdentities.push(identity)
|
||||
result.mergedQueuedIdentities.push(identity)
|
||||
this.mergeQueuedTaskPayload(queuedTask, payload)
|
||||
queuedTask.allowCacheIndex = queuedTask.allowCacheIndex || allowCacheIndex
|
||||
queuedTask.allowFilesystemScan = queuedTask.allowFilesystemScan || allowFilesystemScan
|
||||
queuedTask.scopes.add(scope)
|
||||
if (emitResolved) queuedTask.emitResolvedScopes.add(scope)
|
||||
if (this.priorityScore(priority) < this.priorityScore(queuedTask.priority)) {
|
||||
this.updateQueuedPriority(queuedTask, priority)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.shouldDeferLowPriorityDecrypt(allowDecrypt, priority)) {
|
||||
this.deferLowPriority(result, identity)
|
||||
continue
|
||||
}
|
||||
const queuedPeerTask = this.getQueuedMap(!allowDecrypt).get(identity)
|
||||
const shouldKeepIncomingCacheSeparate = !allowDecrypt && queuedPeerTask?.allowDecrypt === true
|
||||
if (queuedPeerTask && !shouldKeepIncomingCacheSeparate) {
|
||||
if (this.shouldDeferPeerUpgrade(queuedPeerTask, allowDecrypt, priority)) {
|
||||
this.deferLowPriority(result, identity)
|
||||
continue
|
||||
}
|
||||
this.totals.mergedQueued += 1
|
||||
result.mergedQueued += 1
|
||||
result.handledIdentities.push(identity)
|
||||
result.mergedQueuedIdentities.push(identity)
|
||||
this.mergeQueuedTaskPayload(queuedPeerTask, payload)
|
||||
queuedPeerTask.allowCacheIndex = queuedPeerTask.allowCacheIndex || allowCacheIndex
|
||||
queuedPeerTask.allowFilesystemScan = queuedPeerTask.allowFilesystemScan || allowFilesystemScan
|
||||
queuedPeerTask.scopes.add(scope)
|
||||
if (emitResolved) queuedPeerTask.emitResolvedScopes.add(scope)
|
||||
if (allowDecrypt && !queuedPeerTask.allowDecrypt) {
|
||||
this.updateQueuedMode(queuedPeerTask, true)
|
||||
}
|
||||
if (this.priorityScore(priority) < this.priorityScore(queuedPeerTask.priority)) {
|
||||
this.updateQueuedPriority(queuedPeerTask, priority)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.pending.has(key)) {
|
||||
this.totals.skippedPending += 1
|
||||
result.skippedPending += 1
|
||||
result.handledIdentities.push(identity)
|
||||
result.skippedPendingIdentities.push(identity)
|
||||
continue
|
||||
}
|
||||
if (!this.ensureQueueCapacity(priority)) {
|
||||
this.totals.rejectedCapacity += 1
|
||||
if (this.isLowPrioritySoftLimitExceeded(priority)) {
|
||||
this.totals.lowPriorityRejected += 1
|
||||
}
|
||||
result.rejectedCapacity += 1
|
||||
result.rejectedIdentities.push(identity)
|
||||
continue
|
||||
}
|
||||
this.pending.add(key)
|
||||
const task = {
|
||||
...payload,
|
||||
key,
|
||||
identity,
|
||||
allowDecrypt,
|
||||
allowCacheIndex,
|
||||
allowFilesystemScan,
|
||||
scopes: new Set([scope]),
|
||||
emitResolvedScopes: emitResolved ? new Set([scope]) : new Set<string>(),
|
||||
priority
|
||||
}
|
||||
this.addQueuedTask(task)
|
||||
this.totals.accepted += 1
|
||||
result.accepted += 1
|
||||
result.handledIdentities.push(identity)
|
||||
result.acceptedIdentities.push(identity)
|
||||
this.recordHighWater()
|
||||
}
|
||||
this.sortQueue()
|
||||
this.processQueue()
|
||||
return result
|
||||
}
|
||||
|
||||
cancelScope(scope: string): void {
|
||||
const normalizedScope = String(scope || '').trim()
|
||||
if (!normalizedScope) return
|
||||
this.canceledScopes.set(normalizedScope, Date.now())
|
||||
this.dropActiveCacheSnapshotsForScope(normalizedScope)
|
||||
const kept: PreloadTask[] = []
|
||||
for (const task of this.queue) {
|
||||
task.scopes.delete(normalizedScope)
|
||||
task.emitResolvedScopes.delete(normalizedScope)
|
||||
if (this.isCanceled(task)) {
|
||||
this.pending.delete(task.key)
|
||||
this.removeQueuedTask(task)
|
||||
this.totals.canceledQueued += 1
|
||||
} else {
|
||||
kept.push(task)
|
||||
}
|
||||
}
|
||||
this.queue = kept
|
||||
for (const task of this.activeByIdentity.values()) {
|
||||
const hadScope = task.scopes.delete(normalizedScope)
|
||||
task.emitResolvedScopes.delete(normalizedScope)
|
||||
if (!hadScope) continue
|
||||
if (this.isCanceled(task)) {
|
||||
this.totals.canceledActive += 1
|
||||
}
|
||||
}
|
||||
this.pruneCanceledScopes()
|
||||
this.clearLowPriorityWakeIfUnneeded()
|
||||
}
|
||||
|
||||
getStats(): {
|
||||
queued: number
|
||||
pending: number
|
||||
activeCache: number
|
||||
activeDecrypt: number
|
||||
queuedIdentities: number
|
||||
queuedCache: number
|
||||
queuedDecrypt: number
|
||||
queuedHigh: number
|
||||
queuedNormal: number
|
||||
queuedLow: number
|
||||
activeIdentities: number
|
||||
queuedScopes: number
|
||||
activeScopes: number
|
||||
canceledScopes: number
|
||||
highWater: PreloadHighWaterStats
|
||||
totals: {
|
||||
accepted: number
|
||||
mergedQueued: number
|
||||
skippedActive: number
|
||||
ignoredCanceled: number
|
||||
droppedQueued: number
|
||||
canceledQueued: number
|
||||
canceledActive: number
|
||||
promotedActive: number
|
||||
rejectedCapacity: number
|
||||
deferredLowPriority: number
|
||||
lowPriorityIdleDeferrals: number
|
||||
skippedPending: number
|
||||
forcedThumbnailRefreshes: number
|
||||
forcedThumbnailRefreshFailures: number
|
||||
lowPriorityRejected: number
|
||||
activeCacheSnapshots: number
|
||||
activeCacheSnapshotSkipped: number
|
||||
activeCacheSnapshotCanceled: number
|
||||
started: number
|
||||
completed: number
|
||||
}
|
||||
} {
|
||||
this.recordHighWater()
|
||||
const queuedIdentities = new Set([
|
||||
...this.queuedCacheByIdentity.keys(),
|
||||
...this.queuedDecryptByIdentity.keys()
|
||||
]).size
|
||||
const priorityCounts = this.countQueuedPriorities()
|
||||
const highWater = { ...this.highWater }
|
||||
this.resetHighWater()
|
||||
return {
|
||||
queued: this.queue.length,
|
||||
pending: this.pending.size,
|
||||
activeCache: this.activeCache,
|
||||
activeDecrypt: this.activeDecrypt,
|
||||
queuedIdentities,
|
||||
queuedCache: this.queuedCacheByIdentity.size,
|
||||
queuedDecrypt: this.queuedDecryptByIdentity.size,
|
||||
queuedHigh: priorityCounts.high,
|
||||
queuedNormal: priorityCounts.normal,
|
||||
queuedLow: priorityCounts.low,
|
||||
activeIdentities: this.activeByIdentity.size,
|
||||
queuedScopes: this.countTaskScopes(this.queue),
|
||||
activeScopes: this.countTaskScopes(this.activeByIdentity.values()),
|
||||
canceledScopes: this.canceledScopes.size,
|
||||
highWater,
|
||||
totals: { ...this.totals }
|
||||
}
|
||||
}
|
||||
|
||||
private priorityScore(priority: PreloadTask['priority']): number {
|
||||
if (priority === 'high') return 0
|
||||
if (priority === 'normal') return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
private noteForegroundActivity(allowDecrypt: boolean, priority: PreloadTask['priority']): void {
|
||||
if (allowDecrypt && priority === 'low') return
|
||||
this.lastForegroundActivityAt = Date.now()
|
||||
}
|
||||
|
||||
private createEnqueueResult(requested: number): PreloadEnqueueResult {
|
||||
return {
|
||||
success: true,
|
||||
requested,
|
||||
accepted: 0,
|
||||
mergedQueued: 0,
|
||||
skippedActive: 0,
|
||||
skippedPending: 0,
|
||||
ignoredCanceled: 0,
|
||||
rejectedCapacity: 0,
|
||||
deferred: 0,
|
||||
handledIdentities: [],
|
||||
acceptedIdentities: [],
|
||||
mergedQueuedIdentities: [],
|
||||
skippedActiveIdentities: [],
|
||||
skippedPendingIdentities: [],
|
||||
rejectedIdentities: [],
|
||||
deferredIdentities: []
|
||||
}
|
||||
}
|
||||
|
||||
private getTaskIdentity(payload: PreloadImagePayload): string {
|
||||
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) return `md5:${imageMd5}`
|
||||
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
|
||||
if (imageDatName) {
|
||||
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
|
||||
const createTime = Number(payload.createTime || 0) || 0
|
||||
return `dat:${sessionId}|${createTime}|${imageDatName}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private getQueuedMap(allowDecrypt: boolean): Map<string, PreloadTask> {
|
||||
return allowDecrypt ? this.queuedDecryptByIdentity : this.queuedCacheByIdentity
|
||||
}
|
||||
|
||||
private deferLowPriority(result: PreloadEnqueueResult, identity: string): void {
|
||||
this.totals.deferredLowPriority += 1
|
||||
result.deferred += 1
|
||||
result.deferredIdentities.push(identity)
|
||||
}
|
||||
|
||||
private shouldDeferLowPriorityDecrypt(allowDecrypt: boolean, priority: PreloadTask['priority']): boolean {
|
||||
if (!allowDecrypt || priority !== 'low') return false
|
||||
return (
|
||||
this.queuedPriorityCounts.high > 0 ||
|
||||
this.queuedPriorityCounts.normal > 0 ||
|
||||
this.activeCache >= this.maxCacheConcurrent ||
|
||||
(this.activeCache > 0 && this.queuedCacheByIdentity.size > 0)
|
||||
)
|
||||
}
|
||||
|
||||
private shouldDeferPeerUpgrade(
|
||||
peerTask: PreloadTask,
|
||||
allowDecrypt: boolean,
|
||||
priority: PreloadTask['priority']
|
||||
): boolean {
|
||||
return allowDecrypt && !peerTask.allowDecrypt && priority === 'low' && (
|
||||
peerTask.priority !== 'low' ||
|
||||
peerTask.emitResolvedScopes.size > 0
|
||||
)
|
||||
}
|
||||
|
||||
private emitActiveCacheSnapshot(
|
||||
payload: PreloadImagePayload,
|
||||
identity: string,
|
||||
allowCacheIndex: boolean,
|
||||
scope: string
|
||||
): void {
|
||||
if (this.isScopeCanceled(scope)) return
|
||||
const now = Date.now()
|
||||
this.pruneActiveCacheSnapshotCooldowns(now)
|
||||
const snapshotKey = `${scope}|${identity}`
|
||||
if (this.activeCacheSnapshotPending.has(snapshotKey)) {
|
||||
this.totals.activeCacheSnapshotSkipped += 1
|
||||
return
|
||||
}
|
||||
const lastSnapshotAt = this.activeCacheSnapshotLastAt.get(snapshotKey) || 0
|
||||
if (lastSnapshotAt && now - lastSnapshotAt < this.activeCacheSnapshotCooldownMs) {
|
||||
this.totals.activeCacheSnapshotSkipped += 1
|
||||
return
|
||||
}
|
||||
this.activeCacheSnapshotPending.add(snapshotKey)
|
||||
this.activeCacheSnapshotLastAt.set(snapshotKey, now)
|
||||
this.totals.activeCacheSnapshots += 1
|
||||
const cacheKey = this.getSnapshotCacheKey(payload)
|
||||
void imageDecryptService.resolveCachedImage({
|
||||
sessionId: payload.sessionId,
|
||||
imageMd5: payload.imageMd5,
|
||||
imageDatName: payload.imageDatName,
|
||||
createTime: payload.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex,
|
||||
allowCachePromotion: false,
|
||||
suppressEvents: true
|
||||
}).then((result) => {
|
||||
if (this.isScopeCanceled(scope)) {
|
||||
this.totals.activeCacheSnapshotCanceled += 1
|
||||
return
|
||||
}
|
||||
if (!result?.success || !result.localPath || !cacheKey) return
|
||||
imageDecryptService.emitCacheResolvedPath({
|
||||
sessionId: payload.sessionId,
|
||||
imageMd5: payload.imageMd5,
|
||||
imageDatName: payload.imageDatName,
|
||||
createTime: payload.createTime
|
||||
}, cacheKey, result.localPath)
|
||||
}).catch(() => {
|
||||
// Visible cache snapshots are best-effort; the active decrypt will still finish normally.
|
||||
}).finally(() => {
|
||||
this.activeCacheSnapshotPending.delete(snapshotKey)
|
||||
})
|
||||
}
|
||||
|
||||
private getSnapshotCacheKey(payload: PreloadImagePayload): string {
|
||||
return String(payload.imageMd5 || payload.imageDatName || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
private pruneActiveCacheSnapshotCooldowns(now = Date.now()): void {
|
||||
const expiresBefore = now - this.activeCacheSnapshotCooldownMs
|
||||
for (const [key, timestamp] of this.activeCacheSnapshotLastAt.entries()) {
|
||||
if (timestamp >= expiresBefore) continue
|
||||
this.activeCacheSnapshotLastAt.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private dropActiveCacheSnapshotsForScope(scope: string): void {
|
||||
const prefix = `${scope}|`
|
||||
for (const key of Array.from(this.activeCacheSnapshotPending)) {
|
||||
if (key.startsWith(prefix)) this.activeCacheSnapshotPending.delete(key)
|
||||
}
|
||||
for (const key of Array.from(this.activeCacheSnapshotLastAt.keys())) {
|
||||
if (key.startsWith(prefix)) this.activeCacheSnapshotLastAt.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private mergeQueuedTaskPayload(task: PreloadTask, payload: PreloadImagePayload): void {
|
||||
const incomingSessionId = String(payload.sessionId || '').trim()
|
||||
if (!task.sessionId && incomingSessionId) {
|
||||
task.sessionId = incomingSessionId
|
||||
}
|
||||
|
||||
const incomingCreateTime = Number(payload.createTime || 0) || 0
|
||||
const currentCreateTime = Number(task.createTime || 0) || 0
|
||||
if (!currentCreateTime && incomingCreateTime) {
|
||||
task.createTime = incomingCreateTime
|
||||
}
|
||||
|
||||
const incomingImageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
|
||||
if (!task.imageMd5 && incomingImageMd5) {
|
||||
task.imageMd5 = incomingImageMd5
|
||||
}
|
||||
|
||||
const incomingDatName = String(payload.imageDatName || '').trim().toLowerCase()
|
||||
if (!task.imageDatName && incomingDatName) {
|
||||
task.imageDatName = incomingDatName
|
||||
}
|
||||
}
|
||||
|
||||
private addQueuedTask(task: PreloadTask): void {
|
||||
this.queue.push(task)
|
||||
this.getQueuedMap(task.allowDecrypt).set(task.identity, task)
|
||||
this.queuedPriorityCounts[task.priority] += 1
|
||||
}
|
||||
|
||||
private updateQueuedPriority(task: PreloadTask, priority: PreloadTask['priority']): void {
|
||||
if (task.priority === priority) return
|
||||
this.queuedPriorityCounts[task.priority] = Math.max(0, this.queuedPriorityCounts[task.priority] - 1)
|
||||
task.priority = priority
|
||||
this.queuedPriorityCounts[priority] += 1
|
||||
}
|
||||
|
||||
private updateQueuedMode(task: PreloadTask, allowDecrypt: boolean): void {
|
||||
if (task.allowDecrypt === allowDecrypt) return
|
||||
this.getQueuedMap(task.allowDecrypt).delete(task.identity)
|
||||
this.pending.delete(task.key)
|
||||
task.allowDecrypt = allowDecrypt
|
||||
task.key = `${allowDecrypt ? 'decrypt' : 'cache'}|${task.identity}`
|
||||
this.getQueuedMap(task.allowDecrypt).set(task.identity, task)
|
||||
this.pending.add(task.key)
|
||||
}
|
||||
|
||||
private removeQueuedTask(task: PreloadTask): void {
|
||||
this.getQueuedMap(task.allowDecrypt).delete(task.identity)
|
||||
this.queuedPriorityCounts[task.priority] = Math.max(0, this.queuedPriorityCounts[task.priority] - 1)
|
||||
}
|
||||
|
||||
private ensureQueueCapacity(priority: PreloadTask['priority']): boolean {
|
||||
if (this.isLowPrioritySoftLimitExceeded(priority)) {
|
||||
return false
|
||||
}
|
||||
if (this.queue.length < this.maxQueueSize) return true
|
||||
const incomingScore = this.priorityScore(priority)
|
||||
let dropIndex = -1
|
||||
let dropScore = incomingScore
|
||||
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
|
||||
const score = this.priorityScore(this.queue[index].priority)
|
||||
if (score <= dropScore) continue
|
||||
dropScore = score
|
||||
dropIndex = index
|
||||
}
|
||||
if (dropIndex < 0) return false
|
||||
const [dropped] = this.queue.splice(dropIndex, 1)
|
||||
if (dropped) {
|
||||
this.pending.delete(dropped.key)
|
||||
this.removeQueuedTask(dropped)
|
||||
this.totals.droppedQueued += 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private isLowPrioritySoftLimitExceeded(priority: PreloadTask['priority']): boolean {
|
||||
return priority === 'low' && (
|
||||
this.queue.length >= this.lowPrioritySoftQueueSize ||
|
||||
this.queuedPriorityCounts.low >= this.maxLowPriorityQueueSize
|
||||
)
|
||||
}
|
||||
|
||||
private isScopeCanceled(scope: string): boolean {
|
||||
return this.canceledScopes.has(scope)
|
||||
}
|
||||
|
||||
private isCanceled(task: PreloadTask): boolean {
|
||||
if (task.scopes.size === 0) return true
|
||||
for (const scope of task.scopes) {
|
||||
if (!this.isScopeCanceled(scope)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private hasRunnableEmitScope(task: PreloadTask): boolean {
|
||||
for (const scope of Array.from(task.emitResolvedScopes)) {
|
||||
if (task.scopes.has(scope) && !this.isScopeCanceled(scope)) return true
|
||||
task.emitResolvedScopes.delete(scope)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private emitTaskCacheResolved(task: PreloadTask, result?: { success?: boolean; localPath?: string }): void {
|
||||
if (!result?.success || !result.localPath) return
|
||||
if (!this.hasRunnableEmitScope(task)) return
|
||||
const cacheKey = this.getSnapshotCacheKey(task)
|
||||
if (!cacheKey) return
|
||||
imageDecryptService.emitCacheResolvedPath({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime
|
||||
}, cacheKey, result.localPath)
|
||||
}
|
||||
|
||||
private pruneCanceledScopes(): void {
|
||||
if (this.canceledScopes.size === 0) return
|
||||
const referencedScopes = new Set<string>()
|
||||
for (const task of this.queue) {
|
||||
for (const scope of task.scopes) referencedScopes.add(scope)
|
||||
}
|
||||
for (const task of this.activeByIdentity.values()) {
|
||||
for (const scope of task.scopes) referencedScopes.add(scope)
|
||||
}
|
||||
|
||||
const expiresBefore = Date.now() - this.canceledScopeTtlMs
|
||||
for (const [scope, canceledAt] of this.canceledScopes.entries()) {
|
||||
if (referencedScopes.has(scope)) continue
|
||||
if (canceledAt >= expiresBefore) continue
|
||||
this.canceledScopes.delete(scope)
|
||||
}
|
||||
}
|
||||
|
||||
private countTaskScopes(tasks: Iterable<PreloadTask>): number {
|
||||
const scopes = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
for (const scope of task.scopes) {
|
||||
scopes.add(scope)
|
||||
}
|
||||
}
|
||||
return scopes.size
|
||||
}
|
||||
|
||||
private countQueuedPriorities(): Record<PreloadTask['priority'], number> {
|
||||
return { ...this.queuedPriorityCounts }
|
||||
}
|
||||
|
||||
private recordHighWater(): void {
|
||||
const priorityCounts = this.countQueuedPriorities()
|
||||
this.highWater.queued = Math.max(this.highWater.queued, this.queue.length)
|
||||
this.highWater.pending = Math.max(this.highWater.pending, this.pending.size)
|
||||
this.highWater.queuedCache = Math.max(this.highWater.queuedCache, this.queuedCacheByIdentity.size)
|
||||
this.highWater.queuedDecrypt = Math.max(this.highWater.queuedDecrypt, this.queuedDecryptByIdentity.size)
|
||||
this.highWater.queuedHigh = Math.max(this.highWater.queuedHigh, priorityCounts.high)
|
||||
this.highWater.queuedNormal = Math.max(this.highWater.queuedNormal, priorityCounts.normal)
|
||||
this.highWater.queuedLow = Math.max(this.highWater.queuedLow, priorityCounts.low)
|
||||
this.highWater.activeCache = Math.max(this.highWater.activeCache, this.activeCache)
|
||||
this.highWater.activeDecrypt = Math.max(this.highWater.activeDecrypt, this.activeDecrypt)
|
||||
this.highWater.activeIdentities = Math.max(this.highWater.activeIdentities, this.activeByIdentity.size)
|
||||
}
|
||||
|
||||
private resetHighWater(): void {
|
||||
const priorityCounts = this.countQueuedPriorities()
|
||||
this.highWater = {
|
||||
queued: this.queue.length,
|
||||
pending: this.pending.size,
|
||||
queuedCache: this.queuedCacheByIdentity.size,
|
||||
queuedDecrypt: this.queuedDecryptByIdentity.size,
|
||||
queuedHigh: priorityCounts.high,
|
||||
queuedNormal: priorityCounts.normal,
|
||||
queuedLow: priorityCounts.low,
|
||||
activeCache: this.activeCache,
|
||||
activeDecrypt: this.activeDecrypt,
|
||||
activeIdentities: this.activeByIdentity.size
|
||||
}
|
||||
}
|
||||
|
||||
private sortQueue(): void {
|
||||
this.queue.sort((a, b) => this.priorityScore(a.priority) - this.priorityScore(b.priority))
|
||||
}
|
||||
|
||||
private hasRunnableCapacity(): boolean {
|
||||
return this.activeCache < this.maxCacheConcurrent || this.activeDecrypt < this.maxDecryptConcurrent
|
||||
}
|
||||
|
||||
private canRunTask(task: PreloadTask): boolean {
|
||||
if (this.activeByIdentity.has(task.identity)) return false
|
||||
if (task.allowDecrypt && task.priority === 'low' && !this.canRunLowPriorityDecrypt()) return false
|
||||
return task.allowDecrypt
|
||||
? this.activeDecrypt < this.maxDecryptConcurrent
|
||||
: this.activeCache < this.maxCacheConcurrent
|
||||
}
|
||||
|
||||
private canRunLowPriorityDecrypt(): boolean {
|
||||
if (this.activeDecrypt > 0) return false
|
||||
if (this.activeCache > 0) return false
|
||||
if (this.queuedCacheByIdentity.size > 0) return false
|
||||
if (this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0) return false
|
||||
return Date.now() - this.lastForegroundActivityAt >= this.lowPriorityIdleDelayMs
|
||||
}
|
||||
|
||||
private hasQueuedLowPriorityDecrypt(): boolean {
|
||||
return this.queue.some((task) => task.allowDecrypt && task.priority === 'low')
|
||||
}
|
||||
|
||||
private scheduleLowPriorityWakeIfNeeded(): void {
|
||||
if (this.lowPriorityWakeTimer !== null) return
|
||||
if (!this.hasQueuedLowPriorityDecrypt()) return
|
||||
if (this.activeDecrypt > 0 || this.activeCache > 0) return
|
||||
if (this.queuedCacheByIdentity.size > 0) return
|
||||
if (this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0) return
|
||||
const elapsed = Date.now() - this.lastForegroundActivityAt
|
||||
const delayMs = Math.max(20, this.lowPriorityIdleDelayMs - elapsed)
|
||||
this.totals.lowPriorityIdleDeferrals += 1
|
||||
this.lowPriorityWakeTimer = setTimeout(() => {
|
||||
this.lowPriorityWakeTimer = null
|
||||
this.processQueue()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private clearLowPriorityWakeIfUnneeded(): void {
|
||||
if (this.lowPriorityWakeTimer === null) return
|
||||
if (this.hasQueuedLowPriorityDecrypt()) return
|
||||
clearTimeout(this.lowPriorityWakeTimer)
|
||||
this.lowPriorityWakeTimer = null
|
||||
}
|
||||
|
||||
private takeNextRunnableTask(): PreloadTask | null {
|
||||
if (!this.hasRunnableCapacity()) return null
|
||||
const taskIndex = this.queue.findIndex((task) => this.canRunTask(task))
|
||||
if (taskIndex < 0) return null
|
||||
|
||||
const task = this.queue.splice(taskIndex, 1)[0]
|
||||
if (!task) return null
|
||||
this.removeQueuedTask(task)
|
||||
return task
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
while (this.queue.length > 0) {
|
||||
const taskIndex = this.queue.findIndex((task) => (
|
||||
task.allowDecrypt
|
||||
? this.activeDecrypt < this.maxDecryptConcurrent
|
||||
: this.activeCache < this.maxCacheConcurrent
|
||||
))
|
||||
if (taskIndex < 0) return
|
||||
|
||||
const task = this.queue.splice(taskIndex, 1)[0]
|
||||
if (!task) return
|
||||
const task = this.takeNextRunnableTask()
|
||||
if (!task) {
|
||||
this.scheduleLowPriorityWakeIfNeeded()
|
||||
return
|
||||
}
|
||||
this.activeByIdentity.set(task.identity, task)
|
||||
this.totals.started += 1
|
||||
|
||||
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||
else this.activeCache += 1
|
||||
this.recordHighWater()
|
||||
|
||||
void this.handleTask(task).finally(() => {
|
||||
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
|
||||
else this.activeCache = Math.max(0, this.activeCache - 1)
|
||||
this.activeByIdentity.delete(task.identity)
|
||||
this.pending.delete(task.key)
|
||||
this.totals.completed += 1
|
||||
this.pruneCanceledScopes()
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
@@ -70,6 +773,7 @@ export class ImagePreloadService {
|
||||
private async handleTask(task: PreloadTask): Promise<void> {
|
||||
const cacheKey = task.imageMd5 || task.imageDatName
|
||||
if (!cacheKey) return
|
||||
if (this.isCanceled(task)) return
|
||||
try {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
@@ -78,12 +782,52 @@ export class ImagePreloadService {
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
allowCachePromotion: false,
|
||||
allowFilesystemScan: task.allowFilesystemScan,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success) return
|
||||
if (cached.success) {
|
||||
this.emitTaskCacheResolved(task, cached)
|
||||
if (!task.allowDecrypt || this.isCanceled(task)) return
|
||||
const shouldRefreshCachedPreview = cached.hasUpdate === true || isLikelyThumbnailCachePath(cached.localPath)
|
||||
if (!shouldRefreshCachedPreview) return
|
||||
this.totals.forcedThumbnailRefreshes += 1
|
||||
const refreshed = await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
force: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowFilesystemScan: task.allowFilesystemScan,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (!refreshed.success) {
|
||||
this.totals.forcedThumbnailRefreshFailures += 1
|
||||
}
|
||||
if (this.isCanceled(task)) return
|
||||
const resolved = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
allowCachePromotion: false,
|
||||
allowFilesystemScan: task.allowFilesystemScan,
|
||||
suppressEvents: true
|
||||
})
|
||||
this.emitTaskCacheResolved(task, resolved)
|
||||
return
|
||||
}
|
||||
if (!task.allowDecrypt) return
|
||||
if (this.isCanceled(task)) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
@@ -92,8 +836,24 @@ export class ImagePreloadService {
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowFilesystemScan: task.allowFilesystemScan,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (this.isCanceled(task)) return
|
||||
const resolved = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
allowCachePromotion: false,
|
||||
allowFilesystemScan: task.allowFilesystemScan,
|
||||
suppressEvents: true
|
||||
})
|
||||
this.emitTaskCacheResolved(task, resolved)
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user