components_home_LoadLatestRowsTask.bs
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPipeline.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/misc.bs"
sub init()
m.log = new log.Logger("LoadLatestRowsTask")
m.top.functionName = "loadLatestRows"
end sub
' One thread services every latest-media row, at any library count. apiPipeline keeps the
' API pool busy — several requests in flight, one per free slot — while this task stays a
' single thread; the per-library task fan-out it replaced was a `&h29` "too many threads"
' source on large servers (epic #728). See source/api/apiPipeline.bs.
'
' Results ride out as appended children rather than a shared field: rapid writes to one
' field can coalesce and silently drop a row.
sub loadLatestRows()
libs = m.top.libraries
if not isValidAndNotEmpty(libs) then return
m.transformer = JellyfinDataTransformer()
' Read the per-row limit ONCE, outside the loop. This is a Task thread and `m.global` is
' render-owned, so each read of it costs a rendezvous — measured on a Stick 4K at ~93 us
' from a Task thread against ~2 us from the render thread. Per-library it would be one
' crossing per row for a value that cannot change mid-run. See the cost model in
' docs/architecture/async.md.
'
' This row is the one that multiplies: the limit is sent ONCE PER LIBRARY, where every other
' row sharing the setting sends it once. So `uiHomeRowLimit` x libraries is the total this
' screen pays, and it is why the ceiling in settings.json bounds the limit rather than the
' total — see resolveHomeRowLimit.
itemLimit = resolveHomeRowLimit(m.global.user.settings.uiHomeRowLimit)
' Entry order is row order, so on-screen-first rows are submitted first.
entries = []
for each lib in libs
entries.push({
requestId: "latestRow-" + lib.id,
req: GetApi().BuildGetLatestMediaRequest({
"Limit": itemLimit,
"ParentId": lib.id,
"EnableImageTypes": "Primary,Backdrop,Thumb",
"ImageTypeLimit": 1,
"EnableTotalRecordCount": false
}),
libId: lib.id
})
end for
' Split the run into time spent WAITING on the pool versus time spent EMITTING
' (transform + ContentNode + appendChild) on this thread.
'
' "No slower first paint than today" is a success criterion of the task-thread
' work (epic #728), and this run is Home's first paint for latest media. The
' TOTAL alone cannot tell a slow server from slow work of our own, which is the
' distinction every decision here has turned on: measurement showed emit is
' ~50-57% of the run on every device tier, while widening the request pool
' bought nothing below ~150 ms of added latency.
'
' Kept in the source rather than added by hand when needed, because a
' re-instrumented probe measures subtly different things and is therefore not
' comparable to the recorded baselines — which defeats the point of having them.
'
' Gated on `perfTiming`, NOT on `debug`. `roku-log` strips the log CALL from prod
' but not the clocks feeding it, so an ungated version has production run the whole
' measurement and throw the result away. It cannot ride `debug` either: a debug
' build attaches `rawApiData` to every transformed item (JellyfinDataTransformer),
' landing inside `emit` — the quantity measured here — so the only build able to
' read the numbers would be one that distorts them (+121 ms on a Stick 4K).
' `perfTiming` defaults TRUE in the manifest so dev builds keep printing the split;
' scripts/harden-prod-manifest.js forces it false in every release artifact. A
' bsconfig setting CANNOT do that job — see the script's header for why.
' See docs/dev/home-first-paint-performance.md.
'
' Declared unconditionally so bslint (LINT1003) sees every path assign them — it
' does not correlate assignments across `#if` blocks.
runClock = invalid
stepClock = invalid
waitMs = 0
emitMs = 0
#if perfTiming
runClock = CreateObject("roTimespan")
stepClock = CreateObject("roTimespan")
' Second-level split of `emit`, accumulated inside emitRow. `emit` is the
' largest component of this run on every device tier, so it is what any
' optimisation here is aimed at — but the three columns behave completely
' differently and the total hides that:
'
' xform - transform + the carrier node. Thread-LOCAL; the only column more
' worker threads could divide, and the smallest of the three.
' append - appendChild's rendezvous. Near-free.
' notify - the `rowReady` write below. NOT the cost of the write: writing a
' field that the render thread observes parks this thread until the
' observer's callback RETURNS, so this column is HomeRows'
' onLatestRowsReady (drainReady + populateRowFromData) measured from
' the wrong side of the boundary. It is most of `emit`.
'
' The consequence, and it is not the obvious one: `notify` is NOT a cost this
' loop can win back. Only the render thread may serve a rendezvous, so making
' the write non-blocking does not free the run — it just moves the queueing into
' `wait` and `append`, which are rendezvous too. That was prototyped and
' measured: emit fell by a third, every other column rose to match, and `total`
' did not move (p=0.97). So the render thread's per-row work is the floor for
' this run, and neither more worker threads nor a cheaper hand-off changes it.
' Read these columns as a budget for where the run's time went, not as three
' independent things to optimise.
' On `m` rather than through emitRow's signature so the gate keeps the whole
' thing out of a production build.
m.emitClock = CreateObject("roTimespan")
m.xformMs = 0
m.appendMs = 0
m.notifyMs = 0
#end if
pipe = apiPipelineBegin(entries)
#if perfTiming
stepClock.mark()
#end if
result = apiPipelineNext(pipe)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
while isValid(result)
#if perfTiming
stepClock.mark()
#end if
emitRow(result.entry.libId, result.res)
#if perfTiming
emitMs += stepClock.totalMilliseconds()
stepClock.mark()
#end if
result = apiPipelineNext(pipe)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
end while
' The line carries its own build flags so a sample can never be silently compared
' against one taken in a distorting build. Provenance belongs in the sample, not in
' someone's memory of which manifest was checked out at the time.
#if perfTiming
#if debug
buildFlags = " [debug=true perfTiming=true]"
#else
buildFlags = " [debug=false perfTiming=true]"
#end if
m.log.info("latest-rows orchestrator done -" + buildFlags, "task", runClock.totalMilliseconds(), "wait", waitMs, "emit", emitMs)
' A SECOND line, not more arguments on the first: `m.log.*` accepts at most 9
' args including the message, and exceeding it is a runtime fault (&hf1) that
' drops the app into the BrightScript debugger rather than a compile error.
' The three columns must sum to `emit` above — a large gap means the split is
' missing work and none of it can be trusted.
m.log.info("latest-rows emit split -" + buildFlags, "xform", m.xformMs, "append", m.appendMs, "notify", m.notifyMs)
#end if
end sub
' Transforms one response and emits its row.
'
' `res` is invalid when the request never got an answer — the pool was down, or the run
' budget expired. An HTTP error (`res.ok = false`) is deliberately folded into the same
' "failed" status: apiPipeline distinguishes the two, but neither is authoritative about what
' the library HOLDS, which is the only question this row's contents answer. Only an "ok"
' carries a list the UI may act on, including an empty one (which legitimately removes the
' row). HomeRows leaves any existing row alone on "failed" — a populated one keeps its
' content, an unpopulated one keeps its loading placeholder.
sub emitRow(libId as string, res as dynamic)
#if perfTiming
m.emitClock.mark()
#end if
items = []
status = "failed"
if isValid(res) and res.ok
status = "ok"
if isValid(res.json)
' transformBaseItemArray resolves the server version once for the whole batch;
' the per-item call re-reads it (a rendezvous) for every one of the 16 items.
items.append(m.transformer.transformBaseItemArray(supportedItems(res.json)))
end if
else
m.log.warn("latest-media row failed; leaving any existing row in place", libId)
end if
child = CreateObject("roSGNode", "ContentNode")
child.addFields({ libId: libId, items: items, status: status })
' Everything above this point is thread-local: the transform, and a carrier node
' this thread owns until it is appended. Everything below crosses to the render
' thread, once per call each.
#if perfTiming
m.xformMs += m.emitClock.totalMilliseconds()
m.emitClock.mark()
#end if
m.top.appendChild(child)
#if perfTiming
m.appendMs += m.emitClock.totalMilliseconds()
m.emitClock.mark()
#end if
m.top.rowReady = libId
#if perfTiming
m.notifyMs += m.emitClock.totalMilliseconds()
#end if
end sub
' Drops item types the app can't present — Books (issue #525).
function supportedItems(apiItems as object) as object
kept = []
for each item in apiItems
if item.Type <> "Book" then kept.push(item)
end for
return kept
end function