-
Notifications
You must be signed in to change notification settings - Fork 1
chore -cached loading (V3 omezarr support) step one #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
2157f86
make it at least sort of work
froyo-np 256767c
tidy up
froyo-np 87cc1f9
demo
froyo-np f64ba6a
add cleanup method to workerPool, and all the stuff built ontop of wo…
froyo-np 74aa29e
fmt
froyo-np 774004a
undo things I should not have committed
froyo-np fc2f09a
fmt
froyo-np 3a3ff96
why am I so bad at these...
froyo-np 8d285a8
version # bumps
froyo-np 5dd9913
omg
froyo-np File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import type { OmeZarrShapedDataset, OmeZarrMetadata } from './types'; | ||
| import { type ZarrRequest, buildQuery, loadZarrArrayFileFromStore } from './loading'; | ||
| import { VisZarrDataError } from '../errors'; | ||
| import * as zarr from 'zarrita'; | ||
| import { logger } from '@alleninstitute/vis-core'; | ||
| import { ZarrFetchStore, type CachingMultithreadedFetchStoreOptions } from './cached-loading/store'; | ||
|
|
||
| export function decoderFactory(url: string, workerModule: URL, options?: CachingMultithreadedFetchStoreOptions) { | ||
| const store = new ZarrFetchStore(url, workerModule, options); | ||
| const getSlice = async ( | ||
| metadata: OmeZarrMetadata, | ||
| req: ZarrRequest, | ||
| level: OmeZarrShapedDataset, | ||
| signal?: AbortSignal, | ||
| ) => { | ||
| if (metadata.url !== url) { | ||
| throw new Error( | ||
| 'trying to use a decoder from a different store - we cant do that yet, although we could build a map of url->stores here if we wanted later - TODO', | ||
| ); | ||
| } | ||
| const scene = metadata.attrs.multiscales[0]; | ||
| const { axes } = scene; | ||
| if (!level) { | ||
| const message = 'invalid Zarr data: no datasets found'; | ||
| logger.error(message); | ||
| throw new VisZarrDataError(message); | ||
| } | ||
| const arr = metadata.arrays.find((a) => a.path === level.path); | ||
| if (!arr) { | ||
| const message = `cannot load slice: no array found for path [${level.path}]`; | ||
| logger.error(message); | ||
| throw new VisZarrDataError(message); | ||
| } | ||
| const { raw } = await loadZarrArrayFileFromStore(store, arr.path, metadata.zarrVersion, false); | ||
| const result = await zarr.get(raw, buildQuery(req, axes, level.shape), { opts: { signal: signal ?? null } }); | ||
| if (typeof result === 'number') { | ||
| throw new Error('oh noes, slice came back all weird'); | ||
| } | ||
| const { shape, data } = result; | ||
| if (typeof data !== 'object' || !('buffer' in data)) { | ||
| throw new Error('slice was malformed, array-buffer response required'); | ||
| } | ||
| // biome-ignore lint/suspicious/noExplicitAny: <hard to prove - but the typeof check above is sufficient for this to be safe> | ||
| return { shape, data: new Float32Array(data as any) }; | ||
| }; | ||
| return { | ||
| decoder: getSlice, | ||
| destroy: () => { | ||
| store.destroy(); | ||
| }, | ||
| }; | ||
| } | ||
90 changes: 90 additions & 0 deletions
90
packages/omezarr/src/zarr/cached-loading/fetch-data.interface.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import type { AbsolutePath, RangeQuery } from 'zarrita'; | ||
| import z from 'zod'; | ||
|
|
||
| export type TransferrableRequestInit = Omit<RequestInit, 'body' | 'headers' | 'signal'> & { | ||
| body?: string; | ||
| headers?: [string, string][] | Record<string, string>; | ||
| }; | ||
|
|
||
| export type FetchMessagePayload = { | ||
| rootUrl: string; | ||
| path: AbsolutePath; | ||
| range?: RangeQuery | undefined; | ||
| options?: TransferrableRequestInit | undefined; | ||
| }; | ||
|
|
||
| export const FETCH_MESSAGE_TYPE = 'fetch' as const; | ||
| export const FETCH_RESPONSE_MESSAGE_TYPE = 'fetch-response' as const; | ||
| export const CANCEL_MESSAGE_TYPE = 'cancel' as const; | ||
|
|
||
| export type FetchMessage = { | ||
| type: typeof FETCH_MESSAGE_TYPE; | ||
| id: string; | ||
| payload: FetchMessagePayload; | ||
| }; | ||
|
|
||
| export type FetchResponseMessage = { | ||
| type: typeof FETCH_RESPONSE_MESSAGE_TYPE; | ||
| id: string; | ||
| payload: ArrayBufferLike | undefined; | ||
| }; | ||
|
|
||
| export type CancelMessage = { | ||
| type: typeof CANCEL_MESSAGE_TYPE; | ||
| id: string; | ||
| }; | ||
|
|
||
| const FetchMessagePayloadSchema = z.object({ | ||
| rootUrl: z.string().nonempty(), | ||
| path: z.string().nonempty().startsWith('/'), | ||
| range: z | ||
| .union([ | ||
| z.object({ | ||
| offset: z.number(), | ||
| length: z.number(), | ||
| }), | ||
| z.object({ | ||
| suffixLength: z.number(), | ||
| }), | ||
| ]) | ||
| .optional(), | ||
| options: z.unknown().optional(), // being "lazy" for now; doing a full schema for this could be complex and fragile | ||
| }); | ||
|
|
||
| const FetchMessageSchema = z.object({ | ||
| type: z.literal(FETCH_MESSAGE_TYPE), | ||
| id: z.string().nonempty(), | ||
| payload: FetchMessagePayloadSchema, | ||
| }); | ||
|
|
||
| const FetchResponseMessageSchema = z.object({ | ||
| type: z.literal(FETCH_RESPONSE_MESSAGE_TYPE), | ||
| id: z.string().nonempty(), | ||
| payload: z.unknown().optional(), // unclear if it's feasible/wise to define a schema for this one | ||
| }); | ||
|
|
||
| const CancelMessageSchema = z.object({ | ||
| type: z.literal(CANCEL_MESSAGE_TYPE), | ||
| id: z.string().nonempty(), | ||
| }); | ||
|
|
||
| export function isFetchMessage(val: unknown): val is FetchMessage { | ||
| return FetchMessageSchema.safeParse(val).success; | ||
| } | ||
|
|
||
| export function isFetchResponseMessage(val: unknown): val is FetchResponseMessage { | ||
| return FetchResponseMessageSchema.safeParse(val).success; | ||
| } | ||
|
|
||
| export function isCancelMessage(val: unknown): val is CancelMessage { | ||
| return CancelMessageSchema.safeParse(val).success; | ||
| } | ||
|
|
||
| export function isCancellationError(err: unknown): boolean { | ||
| return ( | ||
| err === 'cancelled' || | ||
| (typeof err === 'object' && | ||
| err !== null && | ||
| (('name' in err && err.name === 'AbortError') || ('code' in err && err.code === 20))) | ||
| ); | ||
| } |
124 changes: 124 additions & 0 deletions
124
packages/omezarr/src/zarr/cached-loading/fetch-data.worker-loader.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| // a web-worker which fetches slices of data, decodes them, and returns the result as a flat float32 array, using transferables | ||
|
|
||
| import { HEARTBEAT_RATE_MS, logger } from '@alleninstitute/vis-core'; | ||
| import { type AbsolutePath, FetchStore, type RangeQuery } from 'zarrita'; | ||
| import type { CancelMessage, FetchMessage, TransferrableRequestInit } from './fetch-data.interface'; | ||
| import { | ||
| FETCH_RESPONSE_MESSAGE_TYPE, | ||
| isCancellationError, | ||
| isCancelMessage, | ||
| isFetchMessage, | ||
| } from './fetch-data.interface'; | ||
|
|
||
| const NUM_RETRIES = 2; | ||
| const RETRY_DELAY_MS = 500; | ||
|
|
||
| const fetchFile = async ( | ||
| rootUrl: string, | ||
| path: AbsolutePath, | ||
| options?: TransferrableRequestInit | undefined, | ||
| abortController?: AbortController | undefined, | ||
| ): Promise<Uint8Array | undefined> => { | ||
| const store = new FetchStore(rootUrl); | ||
| return store.get(path, { ...(options || {}), signal: abortController?.signal ?? null }); | ||
| }; | ||
|
|
||
| const fetchSlice = async ( | ||
| rootUrl: string, | ||
| path: AbsolutePath, | ||
| range: RangeQuery, | ||
| options?: TransferrableRequestInit | undefined, | ||
| abortController?: AbortController | undefined, | ||
| ): Promise<Uint8Array | undefined> => { | ||
| const store = new FetchStore(rootUrl); | ||
| const wait = async (ms: number) => | ||
| new Promise((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| for (let i = 0; i < NUM_RETRIES; i++) { | ||
| try { | ||
| return await store.getRange(path, range, { ...(options || {}), signal: abortController?.signal ?? null }); | ||
| } catch (e) { | ||
| logger.error('getRange request failed:', e); | ||
| const hasRetries = i < NUM_RETRIES - 1; | ||
| const message = `getRange request ${i < NUM_RETRIES - 1 ? `will retry in ${RETRY_DELAY_MS}ms` : 'has no retries left'}`; | ||
| logger.warn(message); | ||
| if (hasRetries) { | ||
| await wait(RETRY_DELAY_MS); | ||
| } | ||
| } | ||
| } | ||
| return undefined; | ||
| }; | ||
|
|
||
| const handleFetch = (message: FetchMessage, abortControllers: Record<string, AbortController>) => { | ||
| const { id, payload } = message; | ||
| const { rootUrl, path, range, options } = payload; | ||
|
|
||
| if (id in abortControllers) { | ||
| logger.error('cannot send message: request ID already in use'); | ||
| return; | ||
| } | ||
|
|
||
| const abort = new AbortController(); | ||
| abortControllers[id] = abort; | ||
|
|
||
| const fetchFn = | ||
| range !== undefined | ||
| ? () => fetchSlice(rootUrl, path, range, options, abort) | ||
| : () => fetchFile(rootUrl, path, options, abort); | ||
|
|
||
| fetchFn() | ||
| .then((result: Uint8Array | undefined) => { | ||
| const buffer = result?.buffer; | ||
| const options = buffer !== undefined ? { transfer: [buffer] } : {}; | ||
| self.postMessage( | ||
| { | ||
| type: FETCH_RESPONSE_MESSAGE_TYPE, | ||
| id, | ||
| payload: result?.buffer, | ||
| }, | ||
| { ...options }, | ||
| ); | ||
| }) | ||
| .catch((e) => { | ||
| if (!isCancellationError(e)) { | ||
| logger.error('error in slice fetch worker: ', e); | ||
| } | ||
| // can ignore if it is a cancellation error | ||
| }); | ||
| }; | ||
|
|
||
| const handleCancel = (message: CancelMessage, abortControllers: Record<string, AbortController>) => { | ||
| const { id } = message; | ||
| const abortController = abortControllers[id]; | ||
| if (!abortController) { | ||
| logger.warn('attempted to cancel a non-existent request'); | ||
| } else { | ||
| abortController.abort('cancelled'); | ||
| } | ||
| }; | ||
|
|
||
| const startHeartbeat = () => | ||
| setInterval(() => { | ||
| self.postMessage({ type: 'heartbeat' }); | ||
| }, HEARTBEAT_RATE_MS); | ||
|
|
||
| const setupOnMessage = () => { | ||
| const abortControllers: Record<string, AbortController> = {}; | ||
| const onmessage = async (e: MessageEvent<unknown>) => { | ||
| const { data: message } = e; | ||
|
|
||
| if (isFetchMessage(message)) { | ||
| handleFetch(message, abortControllers); | ||
| } else if (isCancelMessage(message)) { | ||
| handleCancel(message, abortControllers); | ||
| } | ||
| }; | ||
| return onmessage; | ||
| }; | ||
|
|
||
| export const setupFetchDataWorker = (ctx: typeof self) => { | ||
| ctx.onmessage = setupOnMessage(); | ||
| return { startHeartbeat }; | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this file is the only (interesting) non-cherry-picked change here - the rest is directly plucked from #203