Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,33 @@ import {
export class LightcurveApiClient {
private baseUrl: string;
private fluxUrlStub: string;
// Session-lifetime cache for GET requests whose response can't change for a given key (a
// source's data, its lightcurve, a cutout image, ...). Keyed by request-specific strings built
// by each caller. Caches the in-flight promise rather than its resolved value so concurrent
// callers for the same key (e.g. two components requesting the same source) share one request
// instead of firing duplicates.
private cache = new Map<string, Promise<unknown>>();

constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.fluxUrlStub = baseUrl + `/cutouts/flux/`;
}

/** Returns the cached promise for `key`, or runs `fn` and caches its promise. A failed request
* is evicted so it can be retried, rather than caching a permanent rejection. */
private cached<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = this.cache.get(key);
if (existing) {
return existing as Promise<T>;
}
const promise = fn().catch((e: unknown) => {
this.cache.delete(key);
throw e;
});
this.cache.set(key, promise);
return promise;
}

private makeFileName(
object: string,
sourceId: string,
Expand All @@ -40,7 +61,7 @@ export class LightcurveApiClient {
return URL.createObjectURL(blob);
}

private download(url: string, filename: string) {
private download(url: string, filename: string, revoke = true) {
// Create a temporary anchor element to trigger the download
const a = document.createElement('a');
a.href = url;
Expand All @@ -49,8 +70,11 @@ export class LightcurveApiClient {
a.click();
a.remove();

// Clean up the URL
window.URL.revokeObjectURL(url);
// Clean up the URL - skipped for cached URLs (see downloadCutout) since those are shared
// with other consumers (e.g. an open tooltip <img>) and revoking would break them.
if (revoke) {
window.URL.revokeObjectURL(url);
}
}

private async get<T>(path: string): Promise<T> {
Expand All @@ -66,35 +90,53 @@ export class LightcurveApiClient {
}

async getSources() {
return await this.getSource<SourceResponse[]>(`/`);
return await this.cached('sources', () =>
this.getSource<SourceResponse[]>(`/`)
);
}

async getSourceData(id: string) {
return await this.getSource<SourceResponse>(`/${id}`);
return await this.cached(`source:${id}`, () =>
this.getSource<SourceResponse>(`/${id}`)
);
}

async getSourceSummary(id: string) {
return await this.getSource<SourceSummary>(`/${id}/summary`);
return await this.cached(`source-summary:${id}`, () =>
this.getSource<SourceSummary>(`/${id}/summary`)
);
}

async getNearbySources(q: string) {
return await this.getSource<SourceResponse[]>(`/cone${q}`);
return await this.cached(`nearby-sources:${q}`, () =>
this.getSource<SourceResponse[]>(`/cone${q}`)
);
}

async getSourcesFeed(start: number) {
// Not cached: reflects the live/growing source list, so each page should be re-fetched.
return await this.getSource<SourcesFeedResponse>(`/feed?start=${start}`);
}

async getLightcurveData(id: string, selectionStrategy: SelectionStrategy) {
return await this.get<FrequencyLightcurveData | InstrumentLightcurveData>(
`/lightcurves/${id}/unbinned?selection_strategy=${selectionStrategy}`
return await this.cached(`lightcurve:${id}:${selectionStrategy}`, () =>
this.get<FrequencyLightcurveData | InstrumentLightcurveData>(
`/lightcurves/${id}/unbinned?selection_strategy=${selectionStrategy}`
)
);
}

async getCutoutUrl(sourceId: string, measurementId: string, ext: string) {
const endpoint =
this.fluxUrlStub + `${sourceId}/${measurementId}?ext=${ext}`;
return await this.getUrl(endpoint, 'cutout');
// Cached by key so re-clicking (or downloading) the same marker reuses the existing blob URL
// instead of creating a new one every time
return await this.cached(
`cutout:${sourceId}:${measurementId}:${ext}`,
() => {
const endpoint =
this.fluxUrlStub + `${sourceId}/${measurementId}?ext=${ext}`;
return this.getUrl(endpoint, 'cutout');
}
);
}

async downloadCutout(
Expand All @@ -104,7 +146,9 @@ export class LightcurveApiClient {
) {
const url = await this.getCutoutUrl(sourceId, measurementId, ext);
const filename = this.makeFileName('cutout', sourceId, measurementId, ext);
this.download(url, filename);
// getCutoutUrl's blob URL is cached and may still be in use elsewhere (e.g. an open tooltip
// <img>), so don't revoke it here.
this.download(url, filename, false);
}

async downloadTableData(sourceId: string, ext: DataFileExtensions) {
Expand Down
91 changes: 90 additions & 1 deletion tests/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ describe('LightcurveApiClient', () => {

expect(anchor?.download).toBe('cutout-src-1-meas-1.png');
expect(anchor?.href).toBe('blob:mock-url');
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url');
});

it('throws when the cutout fetch fails', async () => {
Expand All @@ -146,6 +145,23 @@ describe('LightcurveApiClient', () => {
client.downloadCutout('src-1', 'meas-1', 'png')
).rejects.toThrow('Failed to get cutout: 404');
});

it('does not revoke the cutout blob url, since getCutoutUrl caches it for reuse elsewhere (e.g. an open tooltip)', async () => {
fetchMock.mockResolvedValueOnce(blobResponse());

await client.downloadCutout('src-1', 'meas-1', 'png');

expect(revokeObjectURLMock).not.toHaveBeenCalled();
});

it('reuses an already-fetched cutout url instead of fetching again', async () => {
fetchMock.mockResolvedValueOnce(blobResponse());

await client.getCutoutUrl('src-1', 'meas-1', 'png');
await client.downloadCutout('src-1', 'meas-1', 'png');

expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

describe('downloadTableData', () => {
Expand All @@ -166,6 +182,79 @@ describe('LightcurveApiClient', () => {
);

expect(anchor?.download).toBe('source-data-src-1.csv');
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url');
});
});

describe('caching', () => {
it('caches GET results by key, fetching only once for repeated calls', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ source_id: '42' }));

const first = await client.getSourceData('42');
const second = await client.getSourceData('42');

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toEqual(first);
});

it('fetches separately for different keys', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ source_id: '1' }))
.mockResolvedValueOnce(jsonResponse({ source_id: '2' }));

await client.getSourceData('1');
await client.getSourceData('2');

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('dedupes concurrent in-flight requests for the same key', async () => {
let resolveFetch: (res: Response) => void = () => {};
fetchMock.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFetch = resolve;
})
);

const first = client.getSourceData('42');
const second = client.getSourceData('42');
resolveFetch(jsonResponse({ source_id: '42' }));

await expect(first).resolves.toEqual({ source_id: '42' });
await expect(second).resolves.toEqual({ source_id: '42' });
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('evicts a failed request so it can be retried', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(null, false, 500));
await expect(client.getSourceData('42')).rejects.toThrow();

fetchMock.mockResolvedValueOnce(jsonResponse({ source_id: '42' }));
const result = await client.getSourceData('42');

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result).toEqual({ source_id: '42' });
});

it('does not cache the sources feed, since it reflects a live/growing list', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ items: [] }))
.mockResolvedValueOnce(jsonResponse({ items: [] }));

await client.getSourcesFeed(10);
await client.getSourcesFeed(10);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('caches cutout urls, reusing the same blob url for repeated requests', async () => {
fetchMock.mockResolvedValueOnce(blobResponse());

const first = await client.getCutoutUrl('src-1', 'meas-1', 'png');
const second = await client.getCutoutUrl('src-1', 'meas-1', 'png');

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
});
});
});
Loading