ADR 0023: Parallel HTTP and background tasks for slow extension work
Context #
Starlark has no threads. Every hook runs in one thread under a 5 second, 20 million step limit, and the catalog gives extensions (tier 1) a 1.5 second budget per query. An extension that calls several web APIs adds the latencies up, and http.download of a large file or a big archive cannot finish inside a hook.
Decision #
http.fetch_all(requests, max_concurrency)is done in Go. The Starlark thread parses every entry first (bodies, multipart files and thefs.readcheck happen there), then blocks while goroutines send them through the sameroundTrippath ashttp.request, so host scoping, per-hop redirect checks and the 8 MiB cap are unchanged. The hook's context is the parent of every request, so the 5 second limit covers the whole call. Failures are returned as entries withok = Falseanderrorrather than raised, because one dead host should not lose the other answers. Limits: 100 requests, concurrency 1 to 8 (default 4), 32 MiB of responses per call.task(fn, args, ttl)starts a top-level function in a separate Starlark thread and returns a handle (id,done,ok,value,error). The result is not delivered to a callback. When a run ends, the extension provider calls the catalog's invalidate function (core.ReadyNotifier, the path the app index already uses), the UI runs the current query again, and the extension's ownon_querycallstask(fn, args)once more and gets the finished handle. Runs are keyed by function name plus the JSON of args, so repeated calls share one run and a finished result is reused forttlseconds (failures too, so a dead service is not retried per keystroke).item(pending = True)is a placeholder row with a no-op action.- Why not
on_task(ctx, id, result)or a pushrefresh(): both need extension state to remember which query a result belongs to, and a second place that builds rows. Re-runningon_querykeeps one code path, works with scopes and keyword routing, and needs no change tocore.Item, the catalog or the UI. The cost is a secondon_querycall per completion, which is cheap because the handle is cached. - Cancellation: each
on_queryoron_scopecall gets a sequence number; when it ends, running tasks that were not requested by it or a later query are cancelled (this includes calls for queries the extension's keywords skip). Tasks also stop onStop, on settings reload and at the limit. Tasks started fromon_executeoron_loadare pinned and run to their end. Cancelling cancels the task's context, which aborts its HTTP requests and SQLite statements. - Limits are separate and the hook limit is unchanged:
Limits.TaskTimeout5 minutes,TaskMaxSteps200 million,TaskBulkBytes1 GiB forhttp.downloadandcompress.create/extract(hooks keep 256 MiB), at most 8 runs at once, 64 results kept. Inside a task,sqlite,httpandprocuse the task's deadline. Row, entry and database-size caps are the same everywhere. A task cannot start a task. - No permission.
taskandpendingonly move work in time; every host call the function makes runs the same checks as in a hook. The result value is frozen so it can pass between threads. schedule(interval, fn)is not implemented. Periodic jobs need a lifetime beyond the launcher window, persisted state, a rule for when the launcher is hidden or the machine sleeps, and a user-visible way to stop them. That is a design of its own, andtaskwithttlcovers refresh-on-demand.
Consequences #
Typing is unaffected: on_query returns at once and tasks run on their own goroutines. Cost per extension is up to 8 extra goroutines, each with a Starlark thread. A completed task triggers one catalog re-run (merged within 30 ms), and every provider answers it again. Built-in providers already have the same facility (Warmer, ReadyNotifier, tier 1), so no built-in counterpart is needed; the definition of done in AGENTS.md says so.