From f42889c3c2753e4d5c057ff93034227e0a531fca Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 10 Apr 2026 10:28:51 -0700 Subject: [PATCH] Improve performance #182: increase profile timer and chat search throttle delays reduce GC pressure in derived stores use requestIdleCallback for non-critical storage writes batch repository update processing in feeds --- src/app/core/requests.ts | 145 +++++++++++++++++++++++---------------- src/app/core/state.ts | 50 ++++++++------ src/app/util/storage.ts | 20 ++++-- 3 files changed, 132 insertions(+), 83 deletions(-) diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index 97060ee9..3dedea09 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -1,5 +1,6 @@ -import {get, writable} from "svelte/store" +import {writable} from "svelte/store" import { + batch, call, uniq, int, @@ -25,7 +26,8 @@ import { sortEventsDesc, } from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util" -import {load, request} from "@welshman/net" +import {load, request, mergeRepositoryUpdates} from "@welshman/net" +import type {RepositoryUpdate} from "@welshman/net" import {repository, loadRelay, tracker} from "@welshman/app" import {createScroller} from "@lib/html" import {daysBetween} from "@lib/util" @@ -56,57 +58,71 @@ export const makeFeed = ({ let backwardWindow = [at - interval, at] let forwardWindow = [at, at + interval] - const insertEvent = (event: TrustedEvent) => { - let handled = false + const insertIntoBuffer = (event: TrustedEvent) => { + for (let i = 0; i < buffer.length; i++) { + if (buffer[i].created_at > event.created_at) { + buffer.splice(i, 0, event) + return + } + } + buffer.push(event) + } - if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { - const $events = get(events) + // Batch-insert events into the visible store with a single update + const insertEvents = (newEvents: TrustedEvent[]) => { + const visible: TrustedEvent[] = [] - for (let i = 0; i < $events.length; i++) { - if ($events[i].created_at > event.created_at) { - events.set(insertAt(i, event, $events)) - handled = true - break + for (const event of newEvents) { + if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { + visible.push(event) + } else { + insertIntoBuffer(event) + } + } + + if (visible.length > 0) { + events.update($events => { + for (const event of visible) { + let inserted = false + for (let i = 0; i < $events.length; i++) { + if ($events[i].created_at > event.created_at) { + $events = insertAt(i, event, $events) + inserted = true + break + } + } + if (!inserted) { + $events = [...$events, event] + } } - } - - if (!handled) { - events.set([...$events, event]) - } - } else { - for (let i = 0; i < buffer.length; i++) { - if (buffer[i].created_at > event.created_at) { - buffer.splice(i, 0, event) - handled = true - break - } - } - - if (!handled) { - buffer.push(event) - } + return $events + }) } } const unsubscribers = [ - on(repository, "update", ({added, removed}) => { + on(repository, "update", batch(16, (updates: RepositoryUpdate[]) => { + const {added, removed} = mergeRepositoryUpdates(updates) + if (removed.size > 0) { buffer = buffer.filter(e => !removed.has(e.id)) events.update($events => $events.filter(e => !removed.has(e.id))) } - for (const event of added) { - if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { - insertEvent(event) - } + const matching = added.filter( + event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url), + ) + + if (matching.length > 0) { + insertEvents(matching) } - }), + })), on(tracker, "add", (id: string, trackerUrl: string) => { if (trackerUrl === url) { const event = repository.getEvent(id) if (event && matchFilters(filters, event)) { - insertEvent(event) + insertEvents([event]) } } }), @@ -137,9 +153,7 @@ export const makeFeed = ({ backwardWindow = [since - interval, since] - for (const event of buffer.splice(0, 30)) { - insertEvent(event) - } + insertEvents(buffer.splice(0, 30)) if (until > now() - int(2, YEAR)) { loadTimeframe(since, until) @@ -160,9 +174,7 @@ export const makeFeed = ({ forwardWindow = [until, until + interval] - for (const event of buffer.splice(0, 30)) { - insertEvent(event) - } + insertEvents(buffer.splice(0, 30)) if (until < now()) { loadTimeframe(since, until) @@ -208,40 +220,57 @@ export const makeCalendarFeed = ({ const events = writable(sortBy(getStart, getEventsForUrl(url, filters))) - const insertEvent = (event: TrustedEvent) => { - const start = getStart(event) - const address = getAddress(event) - - if (isNaN(start) || isNaN(getEnd(event))) return + // Batch-insert calendar events into the store with a single update + const insertEvents = (newEvents: TrustedEvent[]) => { + const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e))) + if (valid.length === 0) return events.update($events => { - for (let i = 0; i < $events.length; i++) { - if ($events[i].id === event.id) return $events - if (getStart($events[i]) > start) return insertAt(i, event, $events) - } + for (const event of valid) { + const start = getStart(event) + const address = getAddress(event) - return [...$events.filter(e => getAddress(e) !== address), event] + let handled = false + for (let i = 0; i < $events.length; i++) { + if ($events[i].id === event.id) { + handled = true + break + } + if (getStart($events[i]) > start) { + $events = insertAt(i, event, $events) + handled = true + break + } + } + + if (!handled) { + $events = [...$events.filter(e => getAddress(e) !== address), event] + } + } + return $events }) } const unsubscribers = [ - on(repository, "update", ({added, removed}) => { + on(repository, "update", batch(16, (updates: RepositoryUpdate[]) => { + const {added, removed} = mergeRepositoryUpdates(updates) + if (removed.size > 0) { events.update($events => $events.filter(e => !removed.has(e.id))) } - for (const event of added) { - if (matchFilters(filters, event)) { - insertEvent(event) - } + const matching = added.filter(event => matchFilters(filters, event)) + + if (matching.length > 0) { + insertEvents(matching) } - }), + })), on(tracker, "add", (id: string, trackerUrl: string) => { if (trackerUrl === url) { const event = repository.getEvent(id) if (event && matchFilters(filters, event)) { - insertEvent(event) + insertEvents([event]) } } }), diff --git a/src/app/core/state.ts b/src/app/core/state.ts index d60cbab4..0cd790c8 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -554,7 +554,7 @@ export const chatsById = call(() => { setTimeout(() => { addEvents(added) removeEvents(removed) - }, 50) + }, 200) }), ] @@ -568,7 +568,7 @@ export const deriveChat = call(() => { return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys)) }) -export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => { +export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => { return createSearch( sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())), { @@ -607,7 +607,7 @@ export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({ }) export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => { - const metaByIdByUrl = new Map>() + const result = new Map() for (const [url, events] of roomMetaEventsByIdByUrl.entries()) { const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values()) @@ -619,6 +619,8 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU } } + const metaById = new Map() + for (const event of metaEvents) { const meta = tryCatch(() => readRoomMeta(event)) @@ -626,22 +628,14 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU continue } - let metaById = metaByIdByUrl.get(url) - if (!metaById) { - metaById = new Map() - metaByIdByUrl.set(url, metaById) - } - const id = makeRoomId(url, meta.h) metaById.set(id, {...meta, url, id}) } - } - const result = new Map() - - for (const [url, metaById] of metaByIdByUrl.entries()) { - result.set(url, Array.from(metaById.values())) + if (metaById.size > 0) { + result.set(url, Array.from(metaById.values())) + } } return result @@ -949,18 +943,32 @@ export const deriveSpaceActionItems = (url: string) => for (const [h, roomEvents] of groupBy(getRoomId, $events)) { if (!h) continue - const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN})) - const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE})) - const roomMembershipEvents = roomEvents.filter(event => - [ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(event.kind), - ) + const roomJoins: TrustedEvent[] = [] + const roomLeaves: TrustedEvent[] = [] + const roomMembershipEvents: TrustedEvent[] = [] + + for (const event of roomEvents) { + switch (event.kind) { + case ROOM_JOIN: + roomJoins.push(event) + break + case ROOM_LEAVE: + roomLeaves.push(event) + break + case ROOM_MEMBERS: + case ROOM_ADD_MEMBER: + case ROOM_REMOVE_MEMBER: + roomMembershipEvents.push(event) + break + } + } + const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents)) pendingJoins.push( ...removeUndefined( Array.from(groupBy(e => e.pubkey, roomJoins).values()) - .map(sortEventsDesc) - .map(first), + .map(events => first(sortEventsDesc(events))), ).filter(({pubkey, created_at}) => { if (roomMembers.has(pubkey)) return false if ( diff --git a/src/app/util/storage.ts b/src/app/util/storage.ts index 5d319a1b..5a7b6dc2 100644 --- a/src/app/util/storage.ts +++ b/src/app/util/storage.ts @@ -48,6 +48,18 @@ import { import type {Unsubscriber} from "svelte/store" import {db} from "@app/core/storage" +// Shared interval for all non-critical store flushes, so they batch on the same cadence +const FLUSH_INTERVAL = 3000 + +// Wraps a write callback to run during idle time (non-critical persistence) +const idleWrite = (f: (xs: T[]) => void): ((xs: T[]) => void) => { + if (typeof requestIdleCallback !== "undefined") { + return (xs: T[]) => requestIdleCallback(() => f(xs)) + } + + return f +} + const kinds = { meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS], alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], @@ -199,14 +211,14 @@ const loadCriticalRelays = async () => { relaysByUrl.set(indexBy(r => r.url, await table.getAll())) } -const syncRelays = () => onRelay(batch(1000, db.table("relays").bulkPut)) +const syncRelays = () => onRelay(batch(FLUSH_INTERVAL, idleWrite(db.table("relays").bulkPut))) const initRelayStats = async () => { const table = db.table("relayStats") relayStatsByUrl.set(indexBy(r => r.url, await table.getAll())) - return onRelayStats(batch(1000, table.bulkPut)) + return onRelayStats(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))) } const initHandles = async () => { @@ -214,7 +226,7 @@ const initHandles = async () => { handlesByNip05.set(indexBy(r => r.nip05, await table.getAll())) - return onHandle(batch(1000, table.bulkPut)) + return onHandle(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))) } const initZappers = async () => { @@ -222,7 +234,7 @@ const initZappers = async () => { zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll())) - return onZapper(batch(3000, table.bulkPut)) + return onZapper(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))) } const initPlaintext = async () => {