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