RPC and worker system
JBrowse runs data-intensive work — parsing adapters, computing layouts, encoding
GPU buffers — in web workers behind an RPC layer. Subclass RpcMethodType with
an execute(), register it with addRpcMethod in your plugin's install(),
and call it with rpcManager.call(sessionId, name, args). Only
structured-clone-safe values cross the boundary.
The RPC lifecycle
serializeArguments and deserializeReturn are yours to override. The
serialize step is where refNames are renamed and functions are stripped, which
is why a method taking regions extends a rename base — see
Renaming regions below.
A sessionId is pinned to one worker, so adapter caches stay warm across calls
from the same session.
Implementing an RPC method
Extend RpcMethodType and implement execute(). GetScoreData from
example-plugins/score-example is a
complete one — it deserializes, resolves the adapter, fetches, and encodes the
result into typed arrays:
import { getFeatureAdapterOrThrow } from '@jbrowse/core/data_adapters/getFeatureAdapter'
import RpcMethodType from '@jbrowse/core/pluggableElementTypes/RpcMethodType'
import { rpcResult } from '@jbrowse/core/util/librpc'
import {
encodeFeatures,
encodedChannelTransferables,
} from '@jbrowse/core/util/markEncoding'
import type { GetScoreDataArgs, ScoreRegionData } from './rpcTypes.ts'
import type { RpcExecuteArgs } from '@jbrowse/core/rpc/RpcRegistry'
// Registering the name here is what types `rpcManager.call(…, 'GetScoreData', …)`
// at every call site: the args are checked and the return type is inferred,
// instead of both being `any`.
declare module '@jbrowse/core/rpc/RpcRegistry' {
interface RpcRegistry {
GetScoreData: {
args: GetScoreDataArgs
return: ScoreRegionData
// wrapped in rpcResult so postMessage transfers its buffers
transferables: true
}
}
}
export default class GetScoreData extends RpcMethodType<'GetScoreData'> {
name = 'GetScoreData' as const
async execute(args: RpcExecuteArgs<'GetScoreData'>) {
const {
sessionId,
adapterConfig,
region,
scoreColumn,
signal,
statusCallback,
} = args
const dataAdapter = await getFeatureAdapterOrThrow({
pluginManager: this.pluginManager,
sessionId,
adapterConfig,
})
// statusCallback arrives as an ordinary function: the caller's never
// crossed the boundary, the RPC layer replaced it with a side channel and
// rebuilt one here. Hand it to whatever does the slow work rather than only
// bracketing that work, so the message tracks the download.
statusCallback?.('Fetching features')
const features = await dataAdapter.getFeaturesArray(region, {
signal,
statusCallback,
})
// The encoder is the packer: one walk reads `scoreColumn` as `y`, skips a
// feature with no finite score, and ships the dense arrays with their
// extremes. The lane list is what the shape reads — `y` here; a display
// that hovers through a Flatbush adds `index`. A packer of your own is for
// a payload the encoder's channels cannot say.
const encoded = encodeFeatures(features, { y: scoreColumn }, ['y'], {
jexl: this.pluginManager.jexl,
})
return rpcResult(encoded, encodedChannelTransferables(encoded))
}
}
deserializeArguments comes first because it handles the blob map and the other
transport concerns; read the args off its result, not off the raw args.
Renaming regions
If your method receives Region objects, their refNames are in the assembly's
naming scheme and the data adapter may use another (chr1 vs 1). Extend
RpcMethodTypeWithRenameRegions, which is that serializeArguments override:
import RpcMethodType from './RpcMethodType.ts'
import type { RenameRegionsArgs } from './RpcMethodType.ts'
// Base for RPC methods whose serialize step just maps region refNames into the
// data adapter's naming scheme. Subclasses get region renaming for free;
// override serializeArguments only to add extra transforms, calling super to
// keep the renaming.
export default abstract class RpcMethodTypeWithRenameRegions<
MethodName extends string = string,
> extends RpcMethodType<MethodName> {
async serializeArguments<T extends RenameRegionsArgs>(args: T) {
return super.serializeArguments(await this.renameRegions(args))
}
}
Two siblings cover the shapes that differ:
RpcMethodTypeWithRenameRegionfor a method taking a singleregionrather than aregionsarray.RpcMethodTypeWithFiltersAndRenameRegions, which additionally deserializes a serialized filter chain; the MAF methods use it.
CoreGetFeatures, CoreGetRegionByteEstimate and CoreGetExportData all use
the plural base.
Returning ArrayBuffers zero-copy
Wrap the result with rpcResult to transfer ArrayBuffers instead of copying
them. The MAF alignment method returns several typed arrays this way:
const regionData: MafWireRegionData = { ...packed, coverage, refSampleId }
const result: LinearMafGetAlignmentDataResult = {
samples,
treeNewick,
samplesCanonical: hasConfiguredSamples,
regionData,
bytes,
}
// second arg is the transfer list: these buffers are moved to the main
// thread, not structured-cloned. collectMafTransferables walks the result and
// gathers every ArrayBuffer in it — a fixed handful, because the wire is
// columnar; see that function for why the length of this list is what the
// whole shape is designed around.
return rpcResult(result, collectMafTransferables(regionData))
A transferred buffer is neutered in the worker — it has zero length there afterwards. That is fine for a value you are returning and never touch again, and a bug if the worker keeps it in a cache.
Registering the method
addRpcMethod takes a factory, called once per realm — the main thread and each
worker construct their own instance:
import GetScoreData from './GetScoreData.ts'
import type PluginManager from '@jbrowse/core/PluginManager'
export default function ScoreRPCF(pluginManager: PluginManager) {
pluginManager.addRpcMethod(() => new GetScoreData(pluginManager))
}
Call that from your plugin's install(), alongside whatever else the plugin
registers:
import Plugin from '@jbrowse/core/Plugin'
import LinearScoreDisplayF from './LinearScoreDisplay/index.ts'
import ScoreFeaturePanelF from './ScoreFeaturePanel/index.tsx'
import ScoreRPCF from './ScoreRPC/index.ts'
import type PluginManager from '@jbrowse/core/PluginManager'
export default class ScoreExamplePlugin extends Plugin {
name = 'ScoreExamplePlugin'
install(pluginManager: PluginManager) {
LinearScoreDisplayF(pluginManager)
ScoreRPCF(pluginManager)
ScoreFeaturePanelF(pluginManager)
}
}
Calling from the main thread
getRpcSessionId(self) is the sticky session id and
getSession(self).rpcManager dispatches. call injects sessionId from its
first parameter.
Two fields work that way, and neither belongs in a registry entry: sessionId
(RpcSession) and the signal/statusCallback pair (RpcHandles). They are
properties of the call, so every method accepts them and no entry gets to
require or refuse one. EntriesDeclaringCallLevelFields in RpcRegistry.ts
fails compilation, naming the entry, if one declares either.
A per-region display does not await the call itself. fetchEachRegion owns
cancellation and staleness, so LinearScoreDisplay hands it the call and a
place to put each result:
// called by the fetch autorun for the regions that need loading;
// fetchEachRegion handles cancellation and staleness
fetchNeeded(needed: { region: Region; displayedRegionIndex: number }[]) {
// no `if (!adapterConfig)` guard: the `adapter` slot is a union of the
// registered adapter schemas, all of which are creatable from an empty
// snapshot, so MST always materializes an object there and the guard
// could never fire
const { adapterConfig } = self
return fetchEachRegion(self, needed, {
// `ctx.callRpc`, never `rpcManager.call`: the context injects this
// fetch's signal and its status callback, and forgetting either
// is silent — no cancellation for this display, or no progress. The
// callback here is this region's own slot in the fan-out, so the N
// parallel calls aggregate into one bar instead of overwriting each
// other
call: (region, ctx) =>
ctx.callRpc('GetScoreData', {
adapterConfig,
region,
...self.rpcProps(),
}),
// what a region stores; the foundation commits it with the region's
// span and fetch inputs as one record
onResult: (_idx, result) => result,
})
},
See Data fetching pipeline for what fetchEachRegion does
with that, and Plotting features in a custom display for the rest of the
model. A one-off call — a dialog, a widget — needs none of it and can
await rpcManager.call(...) directly.
What can cross the worker boundary
The worker boundary uses the Structured Clone Algorithm. Safe types:
- Primitives:
string,number,boolean,null,undefined ArrayBuffer, typed arrays (Uint8Array,Float32Array, …) - use therpcResulttransfer list to avoid copyingFile,Blob- Plain objects and arrays (recursively)
Map,Set,Date,RegExp
Not safe, filtered out automatically:
- Functions and callbacks - use the
statusCallbackmechanism below - MST model nodes or observables
- Circular references
Status callbacks
The RPC layer intercepts statusCallback props and channels them back to the
main thread, which is the one exception to "no functions cross the boundary" —
the function never actually goes; a side-channel does. The main-thread half is
the statusCallback in the call above, which a display reads off its
FetchContext. In a per-region fan-out that context is the region's own, so its
callback is that region's slot in the loading UI and the N of them aggregate
into one bar.
In the worker it arrives deserialized and is called normally. Hand it down to
whatever does the slow work so the message tracks the download — GetScoreData
above passes it into getFeaturesArray.
Type-registering your method
The declare module '@jbrowse/core/rpc/RpcRegistry' block at the top of
GetScoreData above types rpcManager.call at every call site, and
ctx.callRpc with it, which forwards the same registry lookup, so a fetch gets
per-method arg inference and a typed return without naming the session id.
Without the registration both overloads fall back to any, so a misspelled arg
or a wrong assumption about the return type compiles. It goes in the file that
defines the method, so the two can't drift.
Worker count and configuration
workerCount defaults to 0, which means "decide from hardware":
clamp(hardwareConcurrency - 1, 1, 5). Set it to pin a count instead:
{
"configuration": {
"rpc": {
"defaultDriver": "WebWorkerRpcDriver",
"workerCount": 4
}
}
}
(Older sessions stored workerCount under a per-driver drivers map; that
shape is still read and hoisted to the flat slot on load.)
See also
- Data flow, end to end
- Data fetching pipeline
- GPU displays
- Custom adapters
- RefName aliasing
- PROGRESS_REPORTING.md
— what the
statusCallbackabove feeds: determinate bars, aggregation across concurrent fetches, and cancel