feat(extensions): the extension machinery - behaviour, registry, supervision, build gate - #359
Merged
Conversation
…d gate An extension is an OTP application that depends on asobi and is a dependency of the host release, plus one <app>_extension module. Everything else is discovered. - asobi_extension: the behaviour. info/0 required; rpc/0, lua/0, sup/0 and owns/0 optional and empty by default. - asobi_extensions:resolve/0: a pure memoised function over persistent_term, not a process. Its first caller is asobi_router:routes/1 from inside nova_sup:init/1, before asobi_app:start/2 runs and before any asobi process exists. The closure walk is post-order, so the discovered list is already in OTP start order with ties in the host's declaration order. - asobi_extension_sup: a per-extension sub-supervisor with its own restart budget, transient so an extension that spends it goes dark alone instead of escalating into asobi_sup and taking the node with it. - asobi_extension_reserved: core's reserved tables, queues, Lua namespaces and RPC prefixes, derived from core rather than restated. - rebar3 asobi check: the primary gate, running the same check/0 the boot backstop runs. A boot failure surfaces with Nova's crash context; this does not. - asobi_readiness: the 503 not_ready seam. The route table compiles before migrations run, so extension traffic fails closed until they finish. The RPC dispatcher that calls guard/0 is Wave 2b and is not built here. - asobi_repo:migration_apps/0: inert seam for kura 2.20.0's multi-application migration discovery.
kura adds the repo's own application and topologically sorts the result, so the callback names the extensions only. Adds the missing test.
🟡 Code Coverage — 73.8%6042 of 8187 lines covered. |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Wave 3 of the extension architecture: the machinery an extension plugs into.
No extension exists yet (
asobi_questsis Wave 4), so everything here isexercised by fixture extensions under
test/extensions/, and the whole thingis inert when nothing is installed.
Built against
asobi-ecosystem/docs/design/extension-architecture.mdand ADRs0002-0005.
What is here
asobi_extension- the behaviour.info/0is required;rpc/0,lua/0,sup/0andowns/0are-optional_callbacksdefaulting to empty, becauseseveral are frequently empty and
owns/0earns nothing until a secondextension exists.
asobi_extensions:resolve/0- a pure memoised function overpersistent_term. Not a process, not a supervised child, not a step in a bootsequence.
I verified the boot-order claim rather than taking it on trust:
nova_sup:init/1callssetup_cowboy/1->start_cowboy/1->nova_router:compile(...), all insideinit/1(
_build/default/lib/nova/src/nova_sup.erl:82and:145-152), andnovaisin asobi's
applicationslist, so the whole route table compiles beforeasobi_app:start/2runs. The first caller isasobi_router:routes/1, atwhich point no asobi process exists. It holds.
Ordering falls out of discovery rather than being applied afterwards: the
closure walk is post-order, so each application is emitted after everything it
depends on, which is OTP's own start order, with ties in the host's
declaration order. Nothing sorts it afterwards.
asobi_extension_sup+asobi_extension_child_sup- onesub-supervisor per extension with its own restart budget (5 in 60 by default,
{extension_restart, #{intensity, period}}in app env).Restart strategy, since you asked for the justification:
one_for_oneat both levels. Extensions are independent by construction;one restarting is never a reason to restart another.
transient, notpermanent. A supervisor thatexceeds its intensity exits
shutdown, which a transient child is notrestarted for. So an extension that has burned its own budget goes dark and
stays dark, attributed by name. Restarting it would relay the same crash
loop upwards until it reached
asobi_sup(10 in 60, over eighteen childrenincluding the matchmaker and every live match) and took the node with it -
the exact outcome this tree exists to prevent. Transient rather than
temporary because a sub-supervisor killed for some other abnormal reason
is a core-side fault, not an extension giving up.
because a transient child exiting
shutdownis not a restart. The budgetcovers
asobi_extension_watchonly, so nothing an extension does canexhaust it.
asobi_extension_watch- monitors the sub-supervisors and logsextension_downwith the extension's name, so "a feature stopped working andnothing said so" is not a possible outcome.
asobi_extension_sup:running/0answers the same question on demand.
Namespace disjointness -
owns/0plus what the manifest alreadyimplies: the prefixes in
rpc/0and the namespaces inlua/0. So twoextensions installing the same
game.questscollide even before either hasbothered with
owns/0. A conflict names both claimants by application name,which is the thing a host edits.
Core's reserved list is derived from core rather than restated, so it cannot
drift:
asobi_lua_surface:reserved_namespaces/0- the S7 list, plusgameitself.kura_schemamodule'stable/0, found the sameway asobi finds an extension's schemas.
shigoto_workermodule'squeue/0.asobi_error:codes/0, plus the Lua namespaces.An RPC prefix and an error-code domain are the same token by construction
(
{"code": "quests.name_taken"}), so an extension owningstoragewouldmint codes inside core's closed code set.
rebar3 asobi check- the primary gate, running the sameasobi_extensions:check/0the boot backstop runs, so the two cannot disagree.Verified end to end against a scratch host project:
and on a collision, exit 1:
It also runs
supervisor:check_childspecs/1oversup/0, so a malformed childspec is a build-time line naming the extension rather than a
failed_to_start_childout of Nova's boot.asobi_readiness- the 503not_readyseam. The route table compilesduring Nova's boot;
kura_migrator:migrate/1runs later, fromasobi_app:start/2, so an extension endpoint is reachable before its tablesexist.
asobi_readiness:guard/0returnsokor the error object, andasobi_app:start/2marks ready only on a successful migration. The RPCdispatcher is Wave 2b and is not built here -
guard/0and its tests are,so the dispatcher inherits a readiness contract rather than inventing one.
asobi_repo:migration_apps/0- inert seam for kura's multi-applicationmigration discovery.
Inertness
With nothing installed:
resolve/0returns[]without computing thereserved list at all,
asobi_extension_supis one idle supervisor with zerochildren, no timers and no tables, and nothing sits on any request path. The
only always-on cost is one closure walk at boot, memoised.
All 1319 eunit tests pass, including the 1285 that predate this branch.
Design deviations
Three places where the document and reality disagreed.
kura 2.20 is published. The brief said 2.20.0 was not on Hex; it is -
2.20.1, with
migration_apps/0as a documented optionalkura_repocallback. I read the real implementation
(
kura_migrator:resolve_migration_apps/2) and found that kura adds therepo's own application itself and topologically sorts the result, so a
callback returning
[asobi | Extensions]restates an ordering kura alreadyderives.
asobi_repo:migration_apps/0therefore returns the extensionsonly. I did not move the pin:
~> 2.17to~> 2.20is a three-minorupgrade of the ORM under every core migration and deserves its own PR with a
real-Postgres migration run. When that pin moves, this is the entire change
advisory lock, in dependency order. Nothing else here changes.
rebar3 asobi checkships inside asobi, not as arebar3_asobipackage. The design points at rebar3_kura's structure, which is a separate
repo depending on kura. Duplicating the validator there would break the
ADR's requirement that build time and boot validate identically, so the
provider lives in
src/extensions/and is declared to rebar3 through{env, [{providers, [rebar3_asobi_check]}]}inasobi.app.src- Iconfirmed empirically that rebar3 reads that key before falling back to a
module named after the application, so the provider module needs no special
name. The cost is real and should be said plainly: a host adding
asobitoproject_pluginsbuilds asobi's whole dependency tree a second time under_build/default/plugins. Extracting a slimrebar3_asobithat depends onasobi is the follow-up if that cost bites. This also required two
concessions in
rebar.config:xref_ignoresfor rebar3's own API and{exclude_mods, [rebar3_asobi_check]}for dialyzer, both scoped to that onemodule.
rpc/0andlua/0are optional callbacks, though the design says"neither is optional". That sentence is about the design providing both
consumption paths, not about the callback list; a half-written extension
with neither is a legal state and failing the build on it would be hostile.
rebar3 asobi checkwarns instead, naming the application and whynothing can call it. If the intent was a hard failure, say so and it is a
one-line change.
Two smaller judgement calls worth flagging:
asobi_extension_reservedraises if core's module list is unreadablerather than returning an empty reserved set. An empty set silently reserves
nothing, which would let an extension claim
playersand pass the check.Fail closed and loud.
asobi_router:routes/1now callsresolve/0. Extensions contribute noroutes; the call is there because the router is the earliest possible
validation point, which is the whole reason
resolve/0has the shape itdoes.
Tests
34 new tests, all verified to fail when the implementation is reverted:
validate/1never reportsdependency_order_beats_alphabetical_order)_extensionsuffix onlysup_specs/1always emptypermanentinstead oftransienta_spent_extension_goes_dark_alone)asobi_extension_watchremovedTwo assertions carry their weight only because of how the fixtures are
arranged, so they are worth stating:
asobi_fixture_clanssorts beforeasobi_fixture_questsalphabetically but depends on it, so the ordering testdistinguishes dependency order from a sort; and the "an app in asobi's closure
is not an extension" fixture carries a perfectly valid manifest module
belonging to a different application, so it would pass a suffix match and
fails only the name match.
asobi_sup_lua_children_tests:lua_children_start_last_testwas pinning theLua children as the final two entries. Extensions start after them, so it is
now
lua_children_start_after_core_and_before_extensions_testand pins theexact three-entry tail instead - the same guard, restated.
Not built
seam it will call.
asobi_extension_supin step 3, but it was not in the brief's list and itmeans editing
asobi_lua_api:install/2, the probe-VMpick/3path and thenamespace pre-creation order.
lua/0is fully validated here - shape,effects, VM kinds, disjointness, reserved names - so the injector consumes a
checked declaration. It is the obvious next PR and it is not a one-liner.
asobi_bundle.Checks
rebar3 fmt --check,xref,dialyzer,ex_doc(zero warnings), fulleunit(1319 tests, 0 failures), andctforasobi_api_SUITEandasobi_lua_SUITEagainst the shared Postgres. Rebased onto main after #356landed; the only conflict was one line in
asobi_error's code table.