Add segment garbage collection.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent 54a4dd854f
commit 36f14e153c
4 changed files with 118 additions and 16 deletions
+29
View File
@@ -103,6 +103,20 @@ export class MemoryRangedCache<Data> {
} }
return output; return output;
} }
public size(): number {
return this._data.length;
}
/**
* Remove old data that matches a given predicate. No change to the covered
* ranges is made, i.e. this is asserting authoritiatively that this data does
* not exist in the current ranges.
* @param predicate A predicate to run on each data element.
*/
public expireMatches(predicate: (data: Data) => boolean): void {
this._data = this._data.filter(predicate);
}
} }
export class RecordingSegmentsCache { export class RecordingSegmentsCache {
@@ -128,4 +142,19 @@ export class RecordingSegmentsCache {
public get(cameraID: string, range: DateRange): RecordingSegment[] | null { public get(cameraID: string, range: DateRange): RecordingSegment[] | null {
return this._segments.get(cameraID)?.get(range) ?? null; return this._segments.get(cameraID)?.get(range) ?? null;
} }
public getCache(cameraID: string): MemoryRangedCache<RecordingSegment> | null {
return this._segments.get(cameraID) ?? null;
}
public getCameraIDs(): string[] {
return [...this._segments.keys()];
}
public expireMatches(
cameraID: string,
func: (segment: RecordingSegment) => boolean,
): void {
this._segments.get(cameraID)?.expireMatches(func);
}
} }
+82 -6
View File
@@ -1,10 +1,9 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add'; import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour'; import endOfHour from 'date-fns/endOfHour';
import getUnixTime from 'date-fns/getUnixTime';
import startOfHour from 'date-fns/startOfHour'; import startOfHour from 'date-fns/startOfHour';
import { CAMERA_BIRDSEYE } from '../../const'; import { CAMERA_BIRDSEYE } from '../../const';
import { CameraConfig } from '../../types'; import { CameraConfig, RecordingSegment } from '../../types';
import { MediaQueriesResults } from '../../view/media-queries-results'; import { MediaQueriesResults } from '../../view/media-queries-results';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { ViewMedia, ViewMediaFactory } from '../../view/media'; import { ViewMedia, ViewMediaFactory } from '../../view/media';
@@ -42,6 +41,10 @@ import {
} from './requests'; } from './requests';
import { MediaQueries } from '../../view/media-queries'; import { MediaQueries } from '../../view/media-queries';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import { runWhenIdleIfSupported } from '../../utils/basic';
import { fromUnixTime } from 'date-fns';
import { sum } from 'lodash-es';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -74,6 +77,13 @@ class FrigateQueryResultsClassifier {
export class FrigateCameraManagerEngine implements CameraManagerEngine { export class FrigateCameraManagerEngine implements CameraManagerEngine {
protected _recordingSegmentsCache: RecordingSegmentsCache; protected _recordingSegmentsCache: RecordingSegmentsCache;
// Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle(
this._garbageCollectSegments.bind(this),
60 * 60 * 1000,
{ trailing: true },
);
constructor(recordingSegmentsCache: RecordingSegmentsCache) { constructor(recordingSegmentsCache: RecordingSegmentsCache) {
this._recordingSegmentsCache = recordingSegmentsCache; this._recordingSegmentsCache = recordingSegmentsCache;
} }
@@ -220,9 +230,9 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
(!query.end || endHour <= query.end) (!query.end || endHour <= query.end)
) { ) {
recordings.push({ recordings.push({
camera: cameraConfig.frigate.camera_name, cameraID: query.cameraID,
start_time: getUnixTime(startHour), startTime: startHour,
end_time: getUnixTime(endHour), endTime: endHour,
events: hourData.events, events: hourData.events,
}); });
} }
@@ -234,7 +244,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
// this simulates it. // this simulates it.
recordings = orderBy( recordings = orderBy(
recordings, recordings,
(recording: FrigateRecording) => recording.start_time, (recording: FrigateRecording) => recording.startTime,
'desc', 'desc',
).slice(0, query.limit); ).slice(0, query.limit);
} }
@@ -287,6 +297,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
const segments = await getRecordingSegments(hass, request); const segments = await getRecordingSegments(hass, request);
this._recordingSegmentsCache.add(query.cameraID, range, segments); this._recordingSegmentsCache.add(query.cameraID, range, segments);
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras));
return { return {
type: QueryResultsType.RecordingSegments, type: QueryResultsType.RecordingSegments,
engine: Engine.Frigate, engine: Engine.Frigate,
@@ -381,4 +393,68 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
return cameraConfig; return cameraConfig;
} }
/**
* Garbage collect recording segments that no longer feature in the recordings
* returned by the Frigate backend.
*/
protected async _garbageCollectSegments(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
): Promise<void> {
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
const recordingQueries: RecordingQuery[] = cameraIDs.map((cameraID) => ({
cameraID: cameraID,
type: QueryType.Recording,
}));
const countSegments = () =>
sum(
cameraIDs.map(
(cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0,
),
);
const segmentsStart = countSegments();
const results: Map<RecordingQuery, FrigateRecordingQueryResults> = new Map();
await Promise.all(
recordingQueries.map((query) =>
(async () => {
const recordings = await this.getRecordings(hass, cameras, query);
if (recordings && recordings.engine === Engine.Frigate) {
results.set(query, recordings as FrigateRecordingQueryResults);
}
})(),
),
);
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
// items) and each item must be examined, so care required here to stick to
// nothing worse than O(n) performance.
const getHourID = (cameraID: string, startTime: Date): string => {
return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`;
};
for (const [query, result] of results) {
const goodHours: Set<string> = new Set();
for (const recording of result.recordings) {
goodHours.add(getHourID(recording.cameraID, recording.startTime));
}
this._recordingSegmentsCache.expireMatches(
query.cameraID,
(segment: RecordingSegment) => {
const hourID = getHourID(query.cameraID, fromUnixTime(segment.start_time));
// ~O(1) lookup time for a JS set.
return goodHours.has(hourID);
},
);
}
console.debug(
'Frigate Card recording segment garbage collection: ' +
`Released ${segmentsStart - countSegments()} segment(s)`,
);
}
} }
+3 -4
View File
@@ -50,9 +50,8 @@ export const retainResultSchema = z.object({
export type RetainResult = z.infer<typeof retainResultSchema>; export type RetainResult = z.infer<typeof retainResultSchema>;
export interface FrigateRecording { export interface FrigateRecording {
// Frigate camera name (may not be unique) cameraID: string;
camera: string; startTime: Date;
start_time: number; endTime: Date;
end_time: number;
events: number; events: number;
} }
+4 -6
View File
@@ -1,7 +1,7 @@
// Medium: // Medium:
// - TODO: Add garbage collecting of segments not present in the recording summaries anymore.
// - TODO: Move frigate specific view-media under the camera manager. // - TODO: Move frigate specific view-media under the camera manager.
// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
// - TODO: Use sortedUniqBy in the engine sort/uniq combo.
// Hard: // Hard:
// - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events. // - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events.
@@ -85,8 +85,9 @@ export class View {
!curr || !curr ||
prev.view !== curr.view || prev.view !== curr.view ||
prev.camera !== curr.camera || prev.camera !== curr.camera ||
// When in the live view, the target contains the events that happened in // When in the live view, the queryResults contain the events that
// the past -- not reflective of the actual live media viewer. // happened in the past -- not reflective of the actual live media viewer
// the user is seeing.
(curr.view !== 'live' && (curr.view !== 'live' &&
(prev.queryResults !== curr.queryResults || (prev.queryResults !== curr.queryResults ||
prev.queryResults?.getSelectedResult() !== prev.queryResults?.getSelectedResult() !==
@@ -103,9 +104,6 @@ export class View {
camera: this.camera, camera: this.camera,
query: this.query?.clone() ?? null, query: this.query?.clone() ?? null,
queryResults: this.queryResults?.clone() ?? null, queryResults: this.queryResults?.clone() ?? null,
// target: this.target,
// targetIndex: this.targetIndex,
// targetFingerprint: this.targetFingerprint,
context: this.context, context: this.context,
}); });
} }