From 5be6ee86e0ca17f6db6be30113a8d6d28a5faa8f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 2 Jan 2023 17:55:39 -0800 Subject: [PATCH] Timeline performance improvements. --- src/components/timeline-core.ts | 8 ++-- src/utils/data/data-manager-cache.ts | 8 +--- src/utils/data/data-manager-range.ts | 61 ++++++++++++++++++++---- src/utils/timeline-source.ts | 69 ++++++++++++++++++++++------ 4 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c7393d95..1d8ab4b8 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -626,10 +626,12 @@ export class FrigateCardTimelineCore extends LitElement { return null; } + const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( + options?.window ?? this._timeline.getWindow(), + ); + return new EventMediaQueries( - this._timelineSource.getTimelineEventQueries( - options?.window ?? this._timeline.getWindow(), - ), + this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow), ); } diff --git a/src/utils/data/data-manager-cache.ts b/src/utils/data/data-manager-cache.ts index a325285a..ad6ee7d1 100644 --- a/src/utils/data/data-manager-cache.ts +++ b/src/utils/data/data-manager-cache.ts @@ -23,7 +23,7 @@ export class MemoryRequestCache protected _data: RequestCacheItem[] = []; public get(request: Request): Response | null { - const now = this._now(); + const now = new Date(); for (const item of this._data) { if ( (!item.expires || now <= item.expires) && @@ -50,16 +50,12 @@ export class MemoryRequestCache this._expireOldRequests(); } - protected _now(): Date { - return new Date(); - } - protected _contains(a: Request, b: Request): boolean { return isEqual(a, b); } protected _expireOldRequests(): void { - const now = this._now(); + const now = new Date(); this._data = this._data.filter((item) => !item.expires || now < item.expires); } } diff --git a/src/utils/data/data-manager-range.ts b/src/utils/data/data-manager-range.ts index daec7960..6c3b7666 100644 --- a/src/utils/data/data-manager-range.ts +++ b/src/utils/data/data-manager-range.ts @@ -1,4 +1,3 @@ -import cloneDeep from 'lodash-es/cloneDeep'; import orderBy from 'lodash-es/orderBy'; interface Range { @@ -8,20 +7,22 @@ interface Range { export type DateRange = Range; -export class MemoryRangeSet { +interface MemoryRangeSetInterface { + hasCoverage(range: T): boolean; + add(range: T): void; + clear(): void; +} + +export class MemoryRangeSet implements MemoryRangeSetInterface { protected _ranges: DateRange[]; constructor(ranges?: DateRange[]) { this._ranges = ranges ?? []; } - public clone(): MemoryRangeSet { - return new MemoryRangeSet(cloneDeep(this._ranges)); - } - public hasCoverage(range: DateRange): boolean { return this._ranges.some((cachedRange) => - this._isEntirelyContained(cachedRange, range), + rangeIsEntirelyContained(cachedRange, range), ); } @@ -30,11 +31,51 @@ export class MemoryRangeSet { this._ranges = compressRanges(this._ranges); } - protected _isEntirelyContained(bigger: DateRange, smaller: DateRange): boolean { - return smaller.start >= bigger.start && smaller.end <= bigger.end; + public clear(): void { + this._ranges = []; } } +interface ExpiringRange extends Range { + expires: Date; +} + +export class ExpiringMemoryRangeSet + implements MemoryRangeSetInterface> +{ + protected _ranges: ExpiringRange[]; + + constructor(ranges?: ExpiringRange[]) { + this._ranges = ranges ?? []; + } + + public hasCoverage(range: DateRange): boolean { + const now = new Date(); + return this._ranges.some( + (cachedRange) => + now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range), + ); + } + + public add(range: ExpiringRange): void { + this._expireOldRanges(); + this._ranges.push(range); + } + + protected _expireOldRanges(): void { + const now = new Date(); + this._ranges = this._ranges.filter((range) => now < range.expires); + } + + public clear(): void { + this._ranges = []; + } +} + +const rangeIsEntirelyContained = (bigger: DateRange, smaller: DateRange): boolean => { + return smaller.start >= bigger.start && smaller.end <= bigger.end; +}; + export const rangesOverlap = (a: DateRange, b: DateRange): boolean => { return ( // a starts within the range of b. @@ -44,7 +85,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => { // a encompasses the entire range of b. (a.start <= b.start && a.end >= b.end) ); -} +}; export const compressRanges = ( ranges: Range[], diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 4f83422a..badfbf30 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; @@ -9,7 +10,11 @@ import { RecordingSegment, RecordingSegments } from './frigate'; import { capEndDate, convertRangeToCacheFriendlyTimes } from './data/data-manager-util'; import { EventMediaQueries } from '../view'; import { ViewMedia } from '../view-media'; -import { compressRanges, MemoryRangeSet } from './data/data-manager-range'; +import { + compressRanges, + ExpiringMemoryRangeSet, + MemoryRangeSet, +} from './data/data-manager-range'; import { ModifyInterface } from './basic'; // Allow timeline freshness to be at least this number of seconds out of date @@ -35,8 +40,15 @@ export class TimelineDataSource { protected _dataset: DataSet = new DataSet(); // The ranges in which recordings have been calculated and added for. + // Calculating recordings is a very expensive process since it is based on + // segments (not just the fetch is expensive, but the JS to dedup and turn the + // high-N segments into a smaller number of consecutive recording blocks). protected _recordingRanges = new MemoryRangeSet(); + // Cache event ranges since re-adding the same events is a timeline + // performance killer (even if the request results are cached). + protected _eventRanges = new ExpiringMemoryRangeSet(); + protected _cameraIDs: Set; protected _mediaType: ClipsOrSnapshotsOrAll; @@ -55,6 +67,7 @@ export class TimelineDataSource { } public clearEvents(): void { + this._eventRanges.clear(); this._dataset.remove( this._dataset.get({ filter: (item) => item.type !== 'background', @@ -88,13 +101,16 @@ export class TimelineDataSource { ]); } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { - const _window = convertRangeToCacheFriendlyTimes(window, { + public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow { + return convertRangeToCacheFriendlyTimes(window, { endCap: true, }); + } + + public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { return this._dataManager.generateDefaultEventQueries(this._cameraIDs, { - start: _window.start, - end: _window.end, + start: window.start, + end: window.end, ...(this._mediaType === 'clips' && { hasClip: true }), ...(this._mediaType === 'snapshots' && { hasSnapshot: true }), }); @@ -105,7 +121,22 @@ export class TimelineDataSource { cameras: Map, window: TimelineWindow, ): Promise { - const query = new EventMediaQueries(this.getTimelineEventQueries(window)); + if ( + this._eventRanges.hasCoverage({ + start: window.start, + end: sub(capEndDate(window.end), { + seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, + }), + }) + ) { + return; + } + + const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); + const query = new EventMediaQueries( + this.getTimelineEventQueries(cacheFriendlyWindow), + ); + const results = await this._dataManager.executeMediaQuery(hass, query); for (const media of results?.getResults() ?? []) { const endTime = media.getEndTime(); @@ -123,6 +154,11 @@ export class TimelineDataSource { }); } } + + this._eventRanges.add({ + ...cacheFriendlyWindow, + expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }), + }); } protected async _refreshRecordings( @@ -171,14 +207,14 @@ export class TimelineDataSource { // Calculate an end date that's slightly short of the current time to allow // for caching up to the freshness tolerance. - const end = sub(capEndDate(window.end), { - seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, - }); - const hasCoverage = this._recordingRanges.hasCoverage({ - start: window.start, - end: end, - }); - if (hasCoverage) { + if ( + this._recordingRanges.hasCoverage({ + start: window.start, + end: sub(capEndDate(window.end), { + seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS, + }), + }) + ) { return; } @@ -220,6 +256,9 @@ export class TimelineDataSource { addRecordings(compressedRecordings); } - this._recordingRanges.add({ start: window.start, end: end }); + this._recordingRanges.add({ + start: cacheFriendlyWindow.start, + end: cacheFriendlyWindow.end, + }); } }