- Affected components: MediaWiki core and extensions.
- Engineer for initial implementation: TBD.
- Code steward: TBD.
Motivation
I believe the following would be desirable outcomes for this RFC.
Status quo statements that we'd like to uphold:
- Extensions can make small changes to default behaviour of a feature without significant code duplication or large boilerplates, and in a way that isn't mutually exclusive with another extension also making similar changes. (e.g. extensions can collaboratively make small changes to the same implementation).
- Extensions can replace an entire service. (e.g. an extension can replace a feature, in a way that is mutually exclusive with core or other extensions).
Statements we'd like to become true in the future:
- Most extensions can be written in a way that will remain functional in the latest stable MediaWiki release for a long time without any maintenance. ("a long time" is believed to be multiple major release cycles no shorter than the time between LTS releases; using a deprecated feature is considered functional; it was noted that while mandatory maintenance after an LTS release is acceptable, it should still be minimal in most cases)
- Contributors can usually improve core features without needing to break backward compatibility.
- Most extensions can and would by default implement their features in a way that won't significantly slow down user-facing web requests. ("can and would" is meant to signify that the performant way shouldn't require "extra care", but rather good performance should be a natural consequence of an extension author selecting the simplest approach available to them).
Requirements
With this RFC, I'd like to solve problems that affect MediaWiki contributors and extension authors primarily when using the Hooks system. They affect the other extension interfaces as well, but to a lesser extent.
- Hooks expose a lot of (mutable) state.
Hooks often expose high-level class instances, and in their entirety (e.g. passing $this or other high-level objects, as parameters). This means a hook handler is able to vary its return value based on anything in the current state, and also to modify any of that state, and to call any of the public methods on those objects at that time or at later times (deferred).
Allowing this kind of inspection and flexibility doesn't have to be a problem. But, I believe it is currently causing problems because we _also_ don't make a distinction between what is "recommended" and what is possible "at your own risk".
A recommended interface would be officially supported, with breaking changes happening rarely and with public announcements. Ideally with much documentation, examples, and migration guides. An at-your-own-risk approach would still be possible but might require more maintenance to keep compatible, and might sometimes change multiple times between LTSes without (much) documentation.
I believe the absence of this distinction causes the following problems for maintainers:
- It is difficult to limit (or selectively encourage) how extensions will use a certain hook.
- Authors and maintainers of core functionality cannot know what a hook is "supposed" to be used for, or how to change the surrounding code in core in a way that is backwards compatible.
- Contributors to core functionality are unable to change code without potentially breaking an extension.
- Maintaining or improving MediaWiki core is difficult.
And as consequence of the above, there are problems for extension authors as well:
- Extension authors cannot know which hook they should use to implement their custom behaviour in a way that will (likely) remain stable, and that they will (likely) receive support for (in the form of documentation/migration) if and when it does change.
- Extension authors have to deal with breaking changes almost every release cycle.
- Maintaining MediaWiki extensions is difficult and/or requires a lot of time.
Note that this last point applies to both - extensions maintained by Wikimedia Foundation, and those from third parties.
Secondary outcomes
At TechConf 2018, a number of other possbile outcomes were noted as positive, but are not hard requirements for a first step. I believe these are important for MediaWiki in the long-term, and that if not improved by this RFC (e.g. we amend it or go with an alternate proposal), should instead discuss these in a separate RFC. We should not regress on the below factors (and either improve here or later in separate proposals.)
- Encourage database interaction to be in a transaction separate from the main transaction that spans the "pre-send" time of web requests. (E.g. provide eventual consistency via deferred updates or job queue, instead of atomic consistency.)
- Increase operational high-availability of MediaWiki by reducing the chance of cascading failures.
Status quo
First, a brief summary of the status quo. MediaWiki's current extension interfaces are:
- Replace an implementation (high abstraction level).
- Add an implementation (medium abstraction level).
- Hooks (low abstraction level).
Replace an implementation
These are service-like abstractions in MediaWiki for which the implementation can be swapped.
Examples: JobQueue backend (db, redis, ..), FileRepo backend and LockManager (local filesystem, swift, ..), PoolCounter, RCFeed, Database system (mysql, postgres, sqlite, etc.).
The configuration variable has a (sometimes dynamic) default, and a site administrator would set this to a fully-qualified class name of their choosing. The class in question can either be one of the implementations that ship with core, or provided by an extension.
These services typically have only one instance, which will automatically be used by all components in core and other extensions. The reason for changing the implementation is usually to provide the same set of features but with different underlying technology. For example, to improve performance, scalability, or to share operational resources within an organisation etc.
Add an implementation
Several MediaWiki components allow extensions to register additional implementations, associated with a symbolic name. Typically all implementations are available simultaneously (e.g. not mutually exclusive). It is customary (by social convention, sometimes enforced technically) for extensions not to override existing entries, rather, they are only meant to add new ones.
Examples: Special pages, Skins, API modules, Content models, Media Handlers, Parser tags, Parser functions, and Magic words.
Extension have to implement a class interface, subclass, or function signature; and add their implementation to a registry. Usually through an extension attribute, or site configuration.
The core code then provides the bridge to these implementations from the rest of the system. E.g. the Preferences page automatically lists available skins by localised label, the "useskin" parameter supports rendering with any of the installed skins, the Parser automatically scans for added magic words to call the appropriate function etc.
Hooks
A hook allows arbitrary code to run from a callback ("hook handlers") from a specific point in core code. The core code declares that point with Hooks::run( 'TheHookName', [ $a, $b ] );, then extensions implementation a function somewhere that accepts parameters $a and $b (e.g. MyExtensionHooks::onTheHookName) and they add it to the hooks registry (e.g. in extension.json by adding the function name a Hooks.TheHookName array).
See also https://www.mediawiki.org/wiki/Manual:Hooks.
Exploration
Proposal
Drafted by Daniel Kinzler and Timo Tijhof based on discussions during sessions on the first days of TechConf 2018. Presented in the session "Architecting Core: Extension Interface" on the final day. The below has been amended to incorporate the feedback from that session. (Notes on mediawiki.org).
We group hooks into two broad categories: actions, and filters.
Action hooks (aka listeners)
An asynchronous event handler, whereby a handler callback does additional work in response to an action that has occurred.
Their characteristics are:
- Action hooks cannot modify or cancel the default result or behaviour during the user action.
- Action hooks cannot be aborted, returning a value is considered an error.
- Action hooks may do expensive work, which is acceptable because they are asynchronous in nature. That is, they are not part of the atomic transaction that spans the original action. Instead, they are run from a deferred update or job.
- Action hooks are not given mutable values (e.g. value object with setters, or primitives passed by reference). Instead, they are only given immutable value objects and primitives by value, with any other read or write services obtained from the services container as needed.
- An action hook callback cannot be prevented from running by other callbacks. (Exceptions are caught, logged, and then the hook continues; similar to deferred updates). {note: open question}
Filter hooks
A synchronous function to modify a specific value or state.
Their characteristics are:
- Filter hooks allow the callback to modify a value (e.g. a mutable or replaceable value object, or primitive passed by reference).
- Filter hooks may return false to prevent other callbacks for the same filter from running. For example, when an extension disables a piece of functionality it might empty the value and then return false.
- Filter hooks must not do expensive work. Execution time will be profiled and violations above a configurable threshold will be logged as warnings.
- Filter hooks are given only one mutable value, the other parameters must be immutable values, primitives by value, and/or read-only services.
- Filter hooks must be deterministic and must not cause side-effects. Their behaviour should be based on the input parameters only. Other instances and services should not be obtained. {note: open question}
PHP interface
Note: The section for the PHP interface is still an early draft and has not yet received wider input.
We'll need a way to register callbacks, and a way to run callbacks. Today that is Hooks::run(), Hooks::runWithoutAbort(), and extension attribute holding an array of callables like Hooks.<Name>. We could follow the same names and do it as follows:
- Register via ActionHooks.<Name> and FilterHooks.<Name>.
- Run via Hooks::action(), Hooks::filterWithAbort() and Hooks::filter().
In the above interfaces, Hooks::action could hold logic for asserting we are in async context, catch exceptions, and report errors about return values. Hooks::filter could measure execution time (e.g. cumulatively by extension name with a shutdown handler reporting violations with Monolog).
Policy
Note: The section about policy is still an early draft and has not yet received wider input.
- New hooks must be documented in a way that indicates whether they are actions, filters or legacy hooks, and whether they are abortable.
- New hooks of type "action" or "filter" must adhere to the "must" statements above. This means that if there is not yet a value class available to represent the information in question, those classes must be written first. {note: open question}
- ... (more?) ..
Open questions
- If an action hook encounters a fatal error (e.g. class undefined, out of memory, execution timeout), should we ensure other callbacks still run? If so, how? If not, how should we document this?
- The current phrasing allows a filter hook to interact with services not passed as parameter as long as they don't have side-effects (because it says "should not" instead of "must not"). Should this change to a must? If not, what are the use cases. If we make it a "must not", what site configuration? Should we pass Config as parameter to all filters? Or whitelist services->getConfig as the one allowed external call? Or something else?
- Should we include a guideline about batching? It was raised at TechConf 2018 that consumers of "filter hooks" might prefer that they are always called for individual items (no batching within the hook), which encourages code reuse and make pure functions easier. It was also raised that for "action hooks", we may want to standardise on always reflecting data shapes, or always reflecting user workflows. E.g. an action hook about uploads, where a user uploaded 3 files, should that invoke the hook 3x with 1 file (file-oriented), or 1x with a list of 3 files (user-oriented). Or could we leave this open to be decided on a case-by-case basis? Or we could encourage implementations to provide both (seems pretty simple to do).
- Should we require that for all new hooks, if the feature in question does not have value objects, that those are implemented first? If we don't want to require this, how do we move forward? Perhaps we can state that value objects are mandatory if using the new hook system, but that the old system may continue to be used and have new hooks added until a certain point. E.g. leave non-deprecated until 1.34, and deprecate (and disallow new hooks) in 1.35 or something like that, which would give us time to get value objects figured out over 1 year of time before it starts to block introduction of new hooks.