|
| 1 | +0001 Plugin-provided runtime services via entry points |
| 2 | +###################################################### |
| 3 | + |
| 4 | +Status |
| 5 | +****** |
| 6 | + |
| 7 | +Proposed |
| 8 | + |
| 9 | +Context |
| 10 | +******* |
| 11 | + |
| 12 | +XBlocks consume capabilities from their environment through *runtime |
| 13 | +services*: a block declares ``@XBlock.needs("name")`` or |
| 14 | +``@XBlock.wants("name")`` and calls ``self.runtime.service(self, "name")``. |
| 15 | +The base ``Runtime.service()`` resolves the name against the ``_services`` |
| 16 | +dict that the runtime application populated at construction time. |
| 17 | + |
| 18 | +This makes the *consumption* side of services fully generic, but the |
| 19 | +*provision* side closed: only the application that instantiates the runtime |
| 20 | +can decide which services exist. In Open edX — by far the largest user of |
| 21 | +this library — service wiring is hardcoded in several places |
| 22 | +(``ModuleStoreRuntime`` service dicts for LMS/Studio/preview, and the |
| 23 | +``if/elif`` chain in the newer ``XBlockRuntime``), and there is no supported |
| 24 | +way for a separately installed package to offer a new service. |
| 25 | + |
| 26 | +The need is real and recurring. The motivating case is an AI-extensions |
| 27 | +plugin that wants to offer an ``"ai_extensions"`` service so that blocks like |
| 28 | +ORA can call LLM workflows without pinning provider SDKs or importing plugin |
| 29 | +internals (see the community thread in the References). But the same gap |
| 30 | +applies to any optional capability a pip-installed package might offer to |
| 31 | +blocks: translation backends, proctoring integrations, institution-specific |
| 32 | +storage, and so on. |
| 33 | + |
| 34 | +Two facts about the existing design make this library the right place to |
| 35 | +close the gap: |
| 36 | + |
| 37 | +1. **Every runtime already funnels through ``Runtime.service()``.** Open edX |
| 38 | + runtimes either populate ``_services`` and delegate to the base method |
| 39 | + (``ModuleStoreRuntime``), or run their own chain and fall back to the base |
| 40 | + method (``XBlockRuntime``). The xblock-sdk workbench uses the base |
| 41 | + behavior directly. A fallback added here is therefore reached by every |
| 42 | + known runtime without any changes to host applications. |
| 43 | + |
| 44 | +2. **The library already has the discovery machinery and the stated intent.** |
| 45 | + ``xblock/plugin.py`` loads XBlocks (``xblock.v1``) and asides |
| 46 | + (``xblock_asides.v1``) from entry points, with caching, ambiguity |
| 47 | + detection, and an ``.overrides`` group. The reference ``Service`` class in |
| 48 | + ``xblock/reference/plugins.py`` has documented the goal for years: services |
| 49 | + should *"be able to load through Stevedore, and have a plug-in mechanism |
| 50 | + similar to XBlock."* |
| 51 | + |
| 52 | +Decision |
| 53 | +******** |
| 54 | + |
| 55 | +Add a third entry-point group to the XBlock framework, ``xblock.service.v1``, |
| 56 | +and a fallback in ``Runtime.service()`` — the ``_load_service_from_entry_point`` |
| 57 | +method — that consults it. |
| 58 | + |
| 59 | +A package provides a service by declaring:: |
| 60 | + |
| 61 | + entry_points={ |
| 62 | + "xblock.service.v1": [ |
| 63 | + "my_service = my_package.services:MyService", |
| 64 | + ], |
| 65 | + } |
| 66 | + |
| 67 | +where the entry-point name is the service name blocks declare with |
| 68 | +``needs``/``wants``. Resolution order in ``Runtime.service()`` becomes: |
| 69 | + |
| 70 | +1. Reject undeclared requests (unchanged): a block that never declared the |
| 71 | + service still gets ``NoSuchServiceError``. |
| 72 | +2. Return the runtime-provided service from ``_services`` if present |
| 73 | + (unchanged). |
| 74 | +3. **New:** if the runtime has nothing, try |
| 75 | + ``_load_service_from_entry_point(block, service_name)``, which loads the |
| 76 | + provider class from the ``xblock.service.v1`` group and instantiates it as |
| 77 | + ``provider_class(runtime=self, xblock=block)``. |
| 78 | +4. Apply ``need``/``want`` semantics to the result (unchanged): ``None`` for |
| 79 | + a wanted-but-absent service, ``NoSuchServiceError`` for a needed one. |
| 80 | + |
| 81 | +Reasoning behind the specific choices |
| 82 | +===================================== |
| 83 | + |
| 84 | +**Why a fallback in the base class rather than a hook in each runtime.** |
| 85 | +Placing the lookup after the ``_services`` miss, inside the one method every |
| 86 | +runtime inherits, gives complete coverage (all Open edX runtimes, the |
| 87 | +workbench, third-party runtimes that don't override ``service()``) for a |
| 88 | +single small change, and gives a hard guarantee: *runtime-provided services |
| 89 | +always shadow plugin-provided ones*. A pip package cannot replace or |
| 90 | +intercept ``user``, ``field-data``, ``i18n``, or any other service the host |
| 91 | +application provides deliberately. Runtimes that override ``service()`` |
| 92 | +entirely keep that freedom — the fallback only exists in the default path |
| 93 | +they opt into by calling ``super().service()``. |
| 94 | + |
| 95 | +**Why entry points rather than configuration.** Entry points are how this |
| 96 | +library already discovers XBlocks and asides, so providers and operators deal |
| 97 | +with one consistent model: installing a package is the act that makes its |
| 98 | +plugins available, and the trust decision is the install decision — exactly |
| 99 | +as it is for XBlocks themselves. A settings-based registry would be |
| 100 | +runtime-application-specific (this library is not Django-bound) and would put |
| 101 | +the burden of wiring on every operator instead of on the providing package. |
| 102 | + |
| 103 | +**Why the existing ``Plugin`` loader.** Reusing ``Plugin.load_class`` buys, |
| 104 | +for free: per-process caching of hits *and misses* (steady-state cost of the |
| 105 | +fallback is one dict lookup); loud ``AmbiguousPluginError`` when two installed |
| 106 | +packages claim the same service name, instead of last-write-wins — the exact |
| 107 | +failure mode that makes monkey-patching unacceptable; a sanctioned override |
| 108 | +path (``xblock.service.v1.overrides``) when replacing a default implementation |
| 109 | +is intentional; and ``register_temp_plugin`` for tests. |
| 110 | + |
| 111 | +**Why ``provider_class(runtime=…, xblock=…)``.** This mirrors the |
| 112 | +constructor of the reference ``Service`` class, gives the provider the two |
| 113 | +context objects almost every service needs (and from which the rest — user, |
| 114 | +usage key, learning context — is reachable), and keeps the contract so small |
| 115 | +that providers do not need to import ``xblock`` at all. Note that the |
| 116 | +fallback returns an *instance*, never a class: some runtimes |
| 117 | +(``ModuleStoreRuntime``) call callable services with ``(block)``, and a |
| 118 | +class-valued service would be invoked accidentally. Instantiation is |
| 119 | +per-request for now; providers with expensive set-up are expected to cache it |
| 120 | +themselves (module- or class-level), consistent with the long-standing |
| 121 | +"don't over-initialize" guidance in ``reference/plugins.py``. Memoizing per |
| 122 | +``(runtime, service_name)`` in the base class is a possible follow-up once |
| 123 | +real-world usage shows it is needed. |
| 124 | + |
| 125 | +**Why the ``needs``/``wants`` gate stays in front.** The declaration check |
| 126 | +runs before any entry-point lookup, so a plugin-provided service is only ever |
| 127 | +handed to blocks that explicitly asked for it. ``wants`` gives blocks a |
| 128 | +portable soft-dependency: the same block works on installs with and without |
| 129 | +the providing package, enabling features conditionally. |
| 130 | + |
| 131 | +Rejected alternatives |
| 132 | +===================== |
| 133 | + |
| 134 | +* **Wiring extension points into each host-application runtime** (new |
| 135 | + ``openedx.*`` entry-point group, an ``XBLOCK_EXTRA_SERVICES`` Django |
| 136 | + setting, or an openedx-filters filter at resolution time) — all viable, but |
| 137 | + each covers only the call sites it patches, must be replicated for every |
| 138 | + current and future runtime, and lives in repositories whose architectural |
| 139 | + direction is to *shrink* their XBlock-runtime surface, not grow it. These |
| 140 | + were prototyped and documented by the openedx-ai-extensions project (see |
| 141 | + References) before converging here. |
| 142 | + |
| 143 | +Consequences |
| 144 | +************ |
| 145 | + |
| 146 | +* Installed packages can provide named runtime services to consenting blocks |
| 147 | + on any runtime that uses the default resolution path; no host-application |
| 148 | + changes are required. |
| 149 | +* The service namespace becomes shared between runtime applications and |
| 150 | + installed packages. Runtimes always win, and duplicate provider claims |
| 151 | + fail loudly, but a future registry of well-known service names would help |
| 152 | + providers avoid accidental collisions. |
| 153 | +* Operators implicitly accept a package's service registrations by installing |
| 154 | + it, as with XBlocks. If field experience shows a need for finer control, a |
| 155 | + block-list mechanism can be layered on without changing the provider |
| 156 | + contract. |
| 157 | +* The behavior of every existing runtime and block is unchanged unless a |
| 158 | + package registering ``xblock.service.v1`` entry points is installed. |
| 159 | + |
| 160 | +References |
| 161 | +********** |
| 162 | + |
| 163 | +* Community discussion: https://discuss.openedx.org/t/plugin-provided-xblock-runtime-services/18682 |
| 164 | +* Prior analysis and prototypes of the platform-side alternatives: |
| 165 | + ADR-0005 and ADR-0011 in https://github.com/openedx/openedx-ai-extensions |
| 166 | +* Original pluggability intent: ``xblock/reference/plugins.py`` (``Service`` |
| 167 | + docstring) |
| 168 | +* Discovery machinery reused: ``xblock/plugin.py`` |
| 169 | +* Open edX platform ADR *Role of XBlocks* (scope reduction of the platform |
| 170 | + runtime): ``docs/decisions/0006-role-of-xblock.rst`` in edx-platform |
0 commit comments