@speckle/objectloader2
Version:
This is an updated objectloader for the Speckle viewer written in typescript
190 lines (167 loc) • 5.27 kB
text/typescript
import { CustomLogger } from '../types/functions.js'
import { Item } from '../types/types.js'
export interface MemoryCacheOptions {
maxSizeInMb: number
ttlms: number
}
export class MemoryCacheItem {
private item: Item
private expiresAt: number // Timestamp in ms
constructor(item: Item, expiresAt: number) {
this.item = item
this.expiresAt = expiresAt
}
isExpired(now: number): boolean {
return now > this.expiresAt
}
setAccess(now: number, ttl: number): void {
this.expiresAt = now + ttl
}
getItem(): Item {
return this.item
}
done(now: number): boolean {
if (this.isExpired(now)) {
return true
}
return false
}
}
export class MemoryCache {
private isGathered: Map<string, boolean> = new Map()
private references: Map<string, number> = new Map()
private cache: Map<string, MemoryCacheItem> = new Map()
private options: MemoryCacheOptions
private logger: CustomLogger
private disposed = false
private currentSize = 0
private timer?: ReturnType<typeof setTimeout>
constructor(options: MemoryCacheOptions, logger: CustomLogger) {
this.options = options
this.logger = logger
this.resetGlobalTimer()
}
add(item: Item, requestItem: (id: string) => void, testNow?: number): void {
if (this.disposed) throw new Error('MemoryCache is disposed')
this.currentSize += item.size || 0
this.cache.set(
item.baseId,
new MemoryCacheItem(item, (testNow || this.now()) + this.options.ttlms)
)
if (!this.isGathered.has(item.baseId)) {
this.isGathered.set(item.baseId, true)
this.scanForReferences(item.base!, requestItem)
}
}
get(id: string): Item | undefined {
if (this.disposed) throw new Error('MemoryCache is disposed')
const item = this.cache.get(id)
if (item) {
item.setAccess(this.now(), this.options.ttlms)
return item.getItem()
}
return undefined
}
scanForReferences(data: unknown, requestItem: (id: string) => void): void {
const scan = (item: unknown): void => {
// Stop if the item is null or not an object (i.e., primitive)
if (item === null || typeof item !== 'object') {
return
}
// If it's an array, scan each element
if (Array.isArray(item)) {
for (const element of item) {
scan(element)
}
return
}
// If it's an object, scan its properties
for (const key in item) {
if (Object.prototype.hasOwnProperty.call(item, key)) {
// We found the target property!
if (key === 'referencedId') {
const value = (item as { referencedId: unknown }).referencedId
// Ensure the value is a string before adding it
if (typeof value === 'string') {
this.references.set(value, (this.references.get(value) || 0) + 1)
if (!this.cache.has(value)) {
requestItem(value)
}
}
}
// Continue scanning deeper into the object's properties
scan((item as Record<string, unknown>)[key])
}
}
}
scan(data)
}
private resetGlobalTimer(): void {
const run = (): void => {
this.cleanCache()
this.timer = setTimeout(run, this.options.ttlms)
}
this.timer = setTimeout(run, this.options.ttlms)
}
private now(): number {
return Date.now()
}
cleanCache(testNow?: number): void {
const maxSizeBytes = this.options.maxSizeInMb * 1024 * 1024
if (this.currentSize < maxSizeBytes) {
this.logger(
`cache size (${this.currentSize} < ${maxSizeBytes}) is ok, no need to clean`
)
return
}
const now = testNow || this.now()
let cleaned = 0
const start = performance.now()
for (const deferredBase of Array.from(this.cache.values())
.filter((x) => x.isExpired(now))
.sort((a, b) =>
this.compareMaybeBasesByReferences(a.getItem().baseId, b.getItem().baseId)
)) {
if (deferredBase.done(now)) {
const id = deferredBase.getItem().baseId
const referenceCount = this.references.get(id) || 0
if (referenceCount > 0) {
// Skip eviction for items with reference counts greater than 0,
// as they are still in use and should not be removed from the cache.
continue
}
this.currentSize -= deferredBase.getItem().size || 0
this.cache.delete(id)
cleaned++
if (this.currentSize < maxSizeBytes) {
break
}
}
}
this.logger(
`cleaned cache: cleaned ${cleaned}, cached ${this.cache.size}, time ${
performance.now() - start
}`
)
return
}
compareMaybeBasesByReferences(id1: string, id2: string): number {
const a = this.references.get(id1)
const b = this.references.get(id2)
if (a === undefined && b === undefined) return 0
if (a === undefined) return -1
if (b === undefined) return 1
return a - b
}
dispose(): void {
if (this.disposed) return
this.disposed = true
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
this.cache.clear()
this.isGathered.clear()
this.references.clear()
}
}