perf: performance improvements 1

This commit is contained in:
2026-07-14 01:34:33 +02:00
parent edc4d935d8
commit 59dfd2de85
27 changed files with 762 additions and 94 deletions
@@ -210,9 +210,11 @@ Components read attachment state reactively through the store's signals. The sto
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`).
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`). Hydration itself is visibility-gated (`attachment-hydration-visibility.rules.ts`) — off-screen rows never load blobs, and destroyed rows always release theirs.
- **No byte duplication:** disk-hydrated blobs are never copied into `AttachmentRuntimeStore.originalFiles` (`applyAttachmentBlob`); revocation of a disk-backed blob also drops any stale `originalFiles` entry. `originalFiles` only carries uploads/downloads that have no disk copy yet.
- **Room switch sweep:** `releaseDisplayBlobsForInactiveRooms` revokes display blobs for messages of all other rooms when navigation lands on a different room.
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only.
- **Serving** is unaffected: peers still download from `savedPath` / `filePath` (`streamRequestedFile` prefers the disk path); blob URLs are display-only.
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
@@ -10,7 +10,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules';
import {
buildAttachmentDisplayPinKey,
collectMessageIdsForInactiveRoomBlobRelease,
shouldRevokeDisplayBlobForAttachment
} from '../../domain/logic/attachment-blob-eviction.rules';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
@@ -68,8 +72,14 @@ export class AttachmentManagerService {
return;
}
const previousRoomId = this.watchedRoomId;
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
if (this.watchedRoomId !== previousRoomId) {
this.releaseDisplayBlobsForInactiveRooms(this.watchedRoomId);
}
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
@@ -211,6 +221,18 @@ export class AttachmentManagerService {
}
}
releaseDisplayBlobsForInactiveRooms(activeRoomId: string | null): void {
const messageIds = collectMessageIdsForInactiveRoomBlobRelease(
Array.from(this.runtimeStore.getAttachmentEntries(), ([messageId]) => messageId),
(messageId) => this.runtimeStore.getMessageRoomId(messageId) ?? null,
activeRoomId
);
for (const messageId of messageIds) {
this.revokeOffscreenDisplayBlobsForMessage(messageId);
}
}
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment);
}
@@ -134,6 +134,35 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
it('does not duplicate disk-hydrated bytes into the original-file cache', async () => {
const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => of('room-1') } }
]
});
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
const runtimeStore = injector.get(AttachmentRuntimeStore);
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/);
expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined();
});
it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => {
attachmentStorage.canReadFileChunks.mockReturnValue(false);
@@ -263,4 +292,66 @@ describe('AttachmentPersistenceService', () => {
revokeSpy.mockRestore();
});
it('releases the cached original file when revoking a disk-backed display blob', () => {
const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => of('room-1') } }
]
});
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
const runtimeStore = injector.get(AttachmentRuntimeStore);
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: true,
objectUrl: 'blob:http://localhost/abc'
};
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
runtimeStore.setOriginalFile('msg-1:att-1', new File(['abc'], 'photo.png', { type: 'image/png' }));
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined();
revokeSpy.mockRestore();
});
it('keeps the cached original file when the attachment is not persisted to disk yet', () => {
const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => of('room-1') } }
]
});
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
const runtimeStore = injector.get(AttachmentRuntimeStore);
const attachment = {
id: 'att-2',
messageId: 'msg-1',
filename: 'clip.mp4',
size: 3,
mime: 'video/mp4',
isImage: false,
available: true,
objectUrl: 'blob:http://localhost/def'
};
runtimeStore.setOriginalFile('msg-1:att-2', new File(['abc'], 'clip.mp4', { type: 'video/mp4' }));
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(false);
expect(runtimeStore.getOriginalFile('msg-1:att-2')).toBeDefined();
});
});
@@ -141,6 +141,13 @@ export class AttachmentPersistenceService {
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = undefined;
// Once the bytes live on disk, the cached File duplicate is redundant:
// peer requests are served from the disk path and re-display rehydrates
// from disk, so keeping it would double the attachment's memory cost.
if (attachment.savedPath?.trim()) {
this.runtimeStore.deleteOriginalFile(`${attachment.messageId}:${attachment.id}`);
}
return true;
}
@@ -388,15 +395,12 @@ export class AttachmentPersistenceService {
return true;
}
// The blob always comes from a disk path here, so peers are served from
// disk and no original-file copy is cached; caching one would keep a second
// full copy of the bytes alive for the whole session.
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true;
this.runtimeStore.setOriginalFile(
`${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime })
);
this.runtimeStore.touch();
}
@@ -7,6 +7,7 @@ import {
import {
buildAttachmentDisplayPinKey,
canRevokeAttachmentDisplayBlob,
collectMessageIdsForInactiveRoomBlobRelease,
shouldRevokeDisplayBlobForAttachment
} from './attachment-blob-eviction.rules';
@@ -58,4 +59,40 @@ describe('attachment-blob-eviction rules', () => {
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
});
describe('collectMessageIdsForInactiveRoomBlobRelease', () => {
const messageRoomIds = new Map([
['msg-a', 'room-1'],
['msg-b', 'room-2'],
['msg-c', 'room-2']
]);
it('selects messages that belong to rooms other than the active one', () => {
expect(collectMessageIdsForInactiveRoomBlobRelease(
[
'msg-a',
'msg-b',
'msg-c'
],
(messageId) => messageRoomIds.get(messageId) ?? null,
'room-1'
)).toEqual(['msg-b', 'msg-c']);
});
it('skips messages with an unknown room so they are not evicted by mistake', () => {
expect(collectMessageIdsForInactiveRoomBlobRelease(
['msg-a', 'msg-unknown'],
(messageId) => messageRoomIds.get(messageId) ?? null,
'room-2'
)).toEqual(['msg-a']);
});
it('selects every known-room message when no room is active', () => {
expect(collectMessageIdsForInactiveRoomBlobRelease(
['msg-a', 'msg-b'],
(messageId) => messageRoomIds.get(messageId) ?? null,
null
)).toEqual(['msg-a', 'msg-b']);
});
});
});
@@ -45,6 +45,31 @@ export function shouldRevokeDisplayBlobForAttachment(
return canRevokeAttachmentDisplayBlob(attachment);
}
/**
* On room switch, display blobs from every other room are released so their
* memory does not accumulate across the servers a user visits in a session.
* Messages whose room is unknown are left alone rather than evicted blindly.
*/
export function collectMessageIdsForInactiveRoomBlobRelease(
messageIds: Iterable<string>,
resolveMessageRoomId: (messageId: string) => string | null,
activeRoomId: string | null
): string[] {
const selected: string[] = [];
for (const messageId of messageIds) {
const roomId = resolveMessageRoomId(messageId);
if (!roomId || roomId === activeRoomId) {
continue;
}
selected.push(messageId);
}
return selected;
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
@@ -0,0 +1,46 @@
import { shouldHydrateInlineImageForVisibility, shouldHydratePlayableMediaForVisibility } from './attachment-hydration-visibility.rules';
const diskBackedImage = {
available: false,
filename: 'photo.png',
id: 'att-1',
isImage: true,
mime: 'image/png',
savedPath: '/appdata/photo.png'
};
const diskBackedVideo = {
available: false,
mime: 'video/mp4',
savedPath: '/appdata/clip.mp4'
};
describe('attachment hydration visibility rules', () => {
it('hydrates a disk-backed image only when the message is visible', () => {
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, true)).toBe(true);
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, false)).toBe(false);
});
it('never hydrates an image that is already displayable', () => {
const displayable = {
...diskBackedImage,
available: true,
objectUrl: 'blob:http://localhost/abc'
};
expect(shouldHydrateInlineImageForVisibility(displayable, true)).toBe(false);
});
it('hydrates disk-backed playable media only when the message is visible', () => {
expect(shouldHydratePlayableMediaForVisibility(diskBackedVideo, true)).toBe(true);
expect(shouldHydratePlayableMediaForVisibility(diskBackedVideo, false)).toBe(false);
});
it('never hydrates media that already has an object URL', () => {
const hydrated = {
...diskBackedVideo,
objectUrl: 'blob:http://localhost/def'
};
expect(shouldHydratePlayableMediaForVisibility(hydrated, true)).toBe(false);
});
});
@@ -0,0 +1,49 @@
import type { Attachment } from '../models/attachment.model';
import { isAttachmentPendingInlineHydration, isInlineDisplayableImage } from './attachment-image.rules';
import { isAttachmentPendingMediaHydration } from './attachment.logic';
type InlineImageCandidate = Pick<
Attachment,
'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
>;
type PlayableMediaCandidate = Pick<
Attachment,
'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
>;
/**
* Display blobs are only hydrated for messages inside (or near) the viewport.
* Hydrating off-screen rows loads full decoded media into the blob store for
* rows the user may never scroll to, which is the main renderer/browser-process
* memory driver in attachment-heavy rooms.
*/
export function shouldHydrateInlineImageForVisibility(
image: InlineImageCandidate,
isMessageVisible: boolean
): boolean {
if (!isMessageVisible) {
return false;
}
if (isInlineDisplayableImage(image)) {
return false;
}
return isAttachmentPendingInlineHydration(image);
}
export function shouldHydratePlayableMediaForVisibility(
media: PlayableMediaCandidate,
isMessageVisible: boolean
): boolean {
if (!isMessageVisible) {
return false;
}
if (media.objectUrl) {
return false;
}
return isAttachmentPendingMediaHydration(media);
}