206 lines
5.1 KiB
TypeScript
206 lines
5.1 KiB
TypeScript
import type { MarketApiResult } from "./api-client";
|
|
import { createMarketCacheStore } from "./cache-store";
|
|
import type { RowErrorReason, MarketRowState } from "./row-state";
|
|
import type { RequiredAfterSearchRates } from "./types";
|
|
|
|
interface BatchLoaderRow {
|
|
authorId: string | null;
|
|
render(
|
|
state: MarketRowState,
|
|
options?: { onRetry?: () => Promise<void> | void }
|
|
): void;
|
|
}
|
|
|
|
interface LoadRowsOptions {
|
|
listSeq: number;
|
|
rows: BatchLoaderRow[];
|
|
shouldRenderResult?: (params: {
|
|
authorId: string;
|
|
listSeq: number;
|
|
row: BatchLoaderRow;
|
|
}) => boolean;
|
|
}
|
|
|
|
interface MarketBatchLoaderOptions {
|
|
apiClient: {
|
|
loadAuthorAseInfo(authorId: string): Promise<MarketApiResult>;
|
|
};
|
|
cacheStore?: ReturnType<typeof createMarketCacheStore>;
|
|
concurrency?: number;
|
|
}
|
|
|
|
export function createMarketBatchLoader(options: MarketBatchLoaderOptions) {
|
|
const cacheStore = options.cacheStore ?? createMarketCacheStore();
|
|
const concurrency = Math.max(options.concurrency ?? 4, 1);
|
|
|
|
return {
|
|
async loadRows(loadOptions: LoadRowsOptions) {
|
|
const groupedRows = new Map<string, BatchLoaderRow[]>();
|
|
|
|
for (const row of loadOptions.rows) {
|
|
if (!row.authorId) {
|
|
row.render({
|
|
authorId: null,
|
|
listSeq: loadOptions.listSeq,
|
|
reason: "missing-author-id",
|
|
retryable: false,
|
|
state: "error"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const cached = cacheStore.getSuccess(row.authorId);
|
|
if (cached) {
|
|
row.render({
|
|
authorId: row.authorId,
|
|
listSeq: loadOptions.listSeq,
|
|
personalVideoAfterSearchRate: cached.rates.personalVideoAfterSearchRate,
|
|
singleVideoAfterSearchRate: cached.rates.singleVideoAfterSearchRate,
|
|
source: "cache",
|
|
state: "success"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
row.render({
|
|
authorId: row.authorId,
|
|
listSeq: loadOptions.listSeq,
|
|
state: "loading"
|
|
});
|
|
|
|
const rowsForAuthor = groupedRows.get(row.authorId) ?? [];
|
|
rowsForAuthor.push(row);
|
|
groupedRows.set(row.authorId, rowsForAuthor);
|
|
}
|
|
|
|
const tasks = Array.from(groupedRows.entries(), ([authorId, rows]) => {
|
|
return () => loadAuthorRows(authorId, rows, loadOptions);
|
|
});
|
|
|
|
await runWithConcurrency(tasks, concurrency);
|
|
}
|
|
};
|
|
|
|
async function loadAuthorRows(
|
|
authorId: string,
|
|
rows: BatchLoaderRow[],
|
|
loadOptions: LoadRowsOptions
|
|
) {
|
|
const result = await requestAuthor(authorId);
|
|
|
|
for (const row of rows) {
|
|
if (
|
|
loadOptions.shouldRenderResult &&
|
|
!loadOptions.shouldRenderResult({
|
|
authorId,
|
|
listSeq: loadOptions.listSeq,
|
|
row
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
renderAuthorResult(row, authorId, loadOptions.listSeq, result, false);
|
|
}
|
|
}
|
|
|
|
async function retryRow(row: BatchLoaderRow, listSeq: number) {
|
|
if (!row.authorId) {
|
|
return;
|
|
}
|
|
|
|
row.render({
|
|
authorId: row.authorId,
|
|
listSeq,
|
|
state: "loading"
|
|
});
|
|
|
|
const result = await requestAuthor(row.authorId);
|
|
renderAuthorResult(row, row.authorId, listSeq, result, false);
|
|
}
|
|
|
|
async function requestAuthor(authorId: string): Promise<MarketApiResult> {
|
|
const cached = cacheStore.getSuccess(authorId);
|
|
if (cached) {
|
|
return {
|
|
rates: cached.rates,
|
|
success: true
|
|
};
|
|
}
|
|
|
|
const inflight = cacheStore.getInflight<MarketApiResult>(authorId);
|
|
if (inflight) {
|
|
return inflight;
|
|
}
|
|
|
|
const requestPromise = options.apiClient
|
|
.loadAuthorAseInfo(authorId)
|
|
.then((result) => {
|
|
if (result.success) {
|
|
cacheStore.setSuccess(authorId, result.rates);
|
|
}
|
|
return result;
|
|
})
|
|
.finally(() => {
|
|
cacheStore.clearInflight(authorId);
|
|
});
|
|
|
|
cacheStore.setInflight(authorId, requestPromise);
|
|
return requestPromise;
|
|
}
|
|
|
|
function renderAuthorResult(
|
|
row: BatchLoaderRow,
|
|
authorId: string,
|
|
listSeq: number,
|
|
result: MarketApiResult,
|
|
fromCache: boolean
|
|
) {
|
|
if (result.success) {
|
|
const rates = result.rates as RequiredAfterSearchRates;
|
|
row.render({
|
|
authorId,
|
|
listSeq,
|
|
personalVideoAfterSearchRate: rates.personalVideoAfterSearchRate,
|
|
singleVideoAfterSearchRate: rates.singleVideoAfterSearchRate,
|
|
source: fromCache ? "cache" : "network",
|
|
state: "success"
|
|
});
|
|
return;
|
|
}
|
|
|
|
row.render(
|
|
{
|
|
authorId,
|
|
listSeq,
|
|
reason: result.reason as RowErrorReason,
|
|
retryable: true,
|
|
state: "error"
|
|
},
|
|
{
|
|
onRetry: () => retryRow(row, listSeq)
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runWithConcurrency(
|
|
tasks: Array<() => Promise<void>>,
|
|
concurrency: number
|
|
) {
|
|
let nextTaskIndex = 0;
|
|
|
|
const workers = Array.from(
|
|
{ length: Math.min(concurrency, tasks.length) },
|
|
async () => {
|
|
while (nextTaskIndex < tasks.length) {
|
|
const task = tasks[nextTaskIndex];
|
|
nextTaskIndex += 1;
|
|
await task();
|
|
}
|
|
}
|
|
);
|
|
|
|
await Promise.all(workers);
|
|
}
|