fix: make the sync manager a bit more efficient with its tasks

This commit is contained in:
Lei Nelissen
2026-03-10 08:57:25 +01:00
parent 59317a39f9
commit f823b831d5
6 changed files with 65 additions and 40 deletions
-1
View File
@@ -10,7 +10,6 @@ import { ShadowWrapper } from '@/components/Shadow';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import { getSources } from '@/store/sources/actions';
import sources from '@/store/sources/entity';
const Container = styled(SafeAreaView)`
+1 -1
View File
@@ -15,7 +15,7 @@ export const sqliteDb = open({
console.log('[DB] Database path:', sqliteDb.getDbPath());
// Create drizzle instance with v2 relations — exported as singleton
export const db = drizzle(sqliteDb, { schema, relations, logger: true });
export const db = drizzle(sqliteDb, { schema, relations });
/**
* Run database migrations
@@ -82,10 +82,6 @@ export class JellyfinDriver extends SourceDriver {
},
});
if (__DEV__) {
console.log(`%c[HTTP] → [${response.status}] ${url}`, 'font-weight:bold;');
}
if (!response.ok) {
if (response.status === 403 || response.status === 401) {
throw new Error('AuthenticationFailed');
+39 -29
View File
@@ -8,6 +8,7 @@ import {
createCursorIfNotExists,
updateCursorOffset,
markCursorComplete,
isRecentlyCompleted,
} from '../sync-cursors/db';
import { upsertArtists } from '../artists/actions';
import { upsertAlbums } from '../albums/actions';
@@ -19,6 +20,7 @@ import { upsertAlbumSimilar } from '../album-similar/db';
import { setPlaylistTracks } from '../playlist-tracks/db';
import { updateTrackLyrics } from '../tracks/db';
import { driverRegistry } from './drivers/registry';
import { throttle } from 'lodash';
// How many items to request per API call. Large enough to minimise round-trips,
// small enough to keep individual tasks short and resumable.
@@ -69,13 +71,13 @@ export class SourceSync {
*/
private pending: Map<string, DeferredPromise>;
constructor(concurrency = 5) {
constructor(concurrency = 10) {
this.queue = new PQueue({ concurrency });
this.pending = new Map();
this.queue.on('active', () => {
this.queue.on('active', throttle(() => {
console.log('[SYNC] Starting next task. Queue size:', this.queue.size, 'Pending promises:', this.pending.size);
})
}, 1000));
}
// -------------------------------------------------------------------------
@@ -100,9 +102,7 @@ export class SourceSync {
/** Sync all albums from one source, or all sources if omitted. */
async syncAlbums(sourceId?: string): Promise<void> {
console.log('[SYNC] Enqueueing albums sync for sourceId:', sourceId ?? 'ALL');
await this.registerCursor(sourceId, EntityType.ALBUMS);
console.log('[SYNC] Albums sync enqueued for sourceId:', sourceId ?? 'ALL');
}
/** Sync all tracks belonging to the given album. */
@@ -135,9 +135,9 @@ export class SourceSync {
// -------------------------------------------------------------------------
/**
* Creates a sync cursor in the database (if one doesn't already exist) and
* registers a completion promise for it in a single step. Returns a promise
* that resolves when all targeted cursors have completed execution.
* Creates a sync cursor in the database (if one doesn't already exist),
* wires up a completion promise, and adds the task to the queue. Returns a
* promise that resolves when all targeted cursors have completed execution.
*
* When sourceId is omitted the cursor is registered for every known source
* simultaneously, and the returned promise resolves once all of them finish.
@@ -157,27 +157,28 @@ export class SourceSync {
? [sourceId]
: [...driverRegistry.getAll().keys()];
console.log(`[SYNC] Registering cursor for entityType: ${entityType}, parentEntityId: ${parentEntityId}, parentEntityType: ${parentEntityType}, sourceIds: ${sourceIds.join(', ')}`);
console.log(driverRegistry, driverRegistry.getAll());
// Persist a cursor row for each target source so the work survives a
// restart, then collect the completion promise for each one.
const promises = await Promise.all(
sourceIds.map(async (id) => {
// Create the cursor first
const cursor = await createCursorIfNotExists(id, entityType, parentEntityId, parentEntityType);
console.log('[SYNC] Cursor registered:', cursor);
if (!cursor) {
throw new Error(`Failed to create cursor for sourceId: ${id}, entityType: ${entityType}, parentEntityId: ${parentEntityId}, parentEntityType: ${parentEntityType}`);
}
// Then, create a promise we can return to the caller
// If the cursor was completed recently (within the last 60 seconds),
// resolve immediately — there is nothing left to execute.
if (isRecentlyCompleted(cursor)) {
this.resolvePromise(id, entityType, parentEntityId);
return Promise.resolve();
}
// Wire up the completion promise before adding to the queue so the
// resolve handle exists by the time executeTask finishes.
const promise = this.getOrCreatePromise(id, entityType, parentEntityId).promise;
// Finally, add the task to the queue.
this.queue.add(() => this.executeTask(cursor));
return promise;
})
);
@@ -271,7 +272,14 @@ export class SourceSync {
// -------------------------------------------------------------------------
private async executeTask(cursor: SyncCursor): Promise<void> {
console.log('[SYNC] Executing task for cursor', cursor);
// Guard against stale queue entries: if this cursor was completed recently
// (e.g. queued twice via run() and registerCursor), resolve its promise and
// bail out without re-fetching anything.
if (isRecentlyCompleted(cursor)) {
this.resolvePromise(cursor.sourceId, cursor.entityType as EntityType, cursor.parentEntityId ?? '');
return;
}
// A cursor without a matching driver has nowhere to fetch from — skip it.
const driver = driverRegistry.getById(cursor.sourceId);
if (!driver) return;
@@ -384,11 +392,9 @@ export class SourceSync {
}
// For each album, register child cursors for its tracks and similar albums.
// These are written to the database now but executed in the next run() iteration,
// after all album pages have landed — ensuring albums exist before their children run.
for (const album of result.items) {
await this.syncAlbumTracks([sourceId, album.id]);
await this.syncSimilarAlbums([sourceId, album.id]);
this.syncAlbumTracks([sourceId, album.id]);
// this.syncSimilarAlbums([sourceId, album.id]);
}
const newOffset = offset + result.items.length;
@@ -434,9 +440,10 @@ export class SourceSync {
if (track.artistItems?.length) {
await upsertTrackArtists([sourceId, track.id], track.artistItems);
}
// Queue a lyrics fetch for every track. Lyrics are a separate network
// call and will be picked up by the next run() iteration.
await this.syncLyrics([sourceId, track.id]);
// Queue a lyrics fetch for every track
if (track.metadata?.HasLyrics) {
this.syncLyrics([sourceId, track.id]);
}
}
const newOffset = offset + result.items.length;
@@ -466,10 +473,11 @@ export class SourceSync {
sourceId,
})));
// For each playlist, register a child cursor to fetch its tracks. These
// will be picked up in the next run() iteration once all playlist pages are done.
// For each playlist, register a child cursor to fetch its tracks. Do not
// await — awaiting child completion from inside a task slot would deadlock
// the queue.
for (const playlist of result.items) {
await this.syncPlaylistTracks([sourceId, playlist.id]);
this.syncPlaylistTracks([sourceId, playlist.id]);
}
const newOffset = offset + result.items.length;
@@ -525,8 +533,10 @@ export class SourceSync {
if (track.artistItems?.length) {
await upsertTrackArtists([sourceId, track.id], track.artistItems);
}
// Queue a lyrics fetch for this track, to be executed in the next iteration.
await this.syncLyrics([sourceId, track.id]);
// Queue a lyrics fetch for this track
if (track.metadata?.HasLyrics) {
this.syncLyrics([sourceId, track.id]);
}
trackIds.push(track.id);
}
+24 -4
View File
@@ -4,6 +4,8 @@ import { and, eq, isNull } from 'drizzle-orm';
import syncCursors from './entity';
import { EntityType, type SyncCursor } from './types';
const COMPLETED_TTL_MS = 60_000;
export async function getIncompleteCursors(): Promise<SyncCursor[]> {
return db.select().from(syncCursors).where(eq(syncCursors.completed, false)).all();
}
@@ -23,7 +25,14 @@ export async function createCursorIfNotExists(
startIndex: 0,
pageSize,
completed: false,
}).onConflictDoNothing();
}).onConflictDoUpdate({
target: [syncCursors.sourceId, syncCursors.entityType, syncCursors.parentEntityId],
set: {
completed: false,
startIndex: 0,
updatedAt: Date.now(),
},
});
await sqliteDb.flushPendingReactiveQueries();
@@ -34,7 +43,7 @@ export async function createCursorIfNotExists(
parentEntityId: parentEntityId,
parentEntityType: parentEntityType,
}
})
});
return cursor;
}
@@ -46,7 +55,7 @@ export async function updateCursorOffset(
parentEntityId?: string,
): Promise<void> {
await db.update(syncCursors)
.set({ startIndex: newOffset, updatedAt: Date.now() })
.set({ startIndex: newOffset })
.where(and(
eq(syncCursors.sourceId, sourceId),
eq(syncCursors.entityType, entityType),
@@ -60,10 +69,21 @@ export async function markCursorComplete(
parentEntityId?: string,
): Promise<void> {
await db.update(syncCursors)
.set({ completed: true, updatedAt: Date.now() })
.set({ completed: true })
.where(and(
eq(syncCursors.sourceId, sourceId),
eq(syncCursors.entityType, entityType),
parentEntityId !== undefined ? eq(syncCursors.parentEntityId, parentEntityId) : isNull(syncCursors.parentEntityId),
));
}
/**
* Returns true if the cursor was completed within the last COMPLETED_TTL_MS
* milliseconds. Use this instead of checking cursor.completed directly — the
* completed flag is reset to false by createCursorIfNotExists on each new sync
* cycle, so a stale completed=true from a previous run should not be treated as
* done.
*/
export function isRecentlyCompleted(cursor: SyncCursor): boolean {
return cursor.completed && (Date.now() - cursor.updatedAt) < COMPLETED_TTL_MS;
}
+1 -1
View File
@@ -17,7 +17,7 @@ const syncCursors = sqliteTable('sync_cursors', {
attempts: integer('attempts').notNull().default(0),
failedAt: integer('failed_at'),
lastError: text('last_error'),
updatedAt: integer('updated_at').notNull().$default(() => Date.now()),
updatedAt: integer('updated_at').notNull().$default(() => Date.now()).$onUpdate(() => Date.now()),
}, (table) => [
primaryKey({ columns: [table.sourceId, table.entityType, table.parentEntityId] }),
]);