docs: propose a pluggable search backend for Studio content search - #39049
docs: propose a pluggable search backend for Studio content search#39049blarghmatey wants to merge 1 commit into
Conversation
|
Thanks for the pull request, @blarghmatey! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
ADR 0001 chose Meilisearch for content/search as an experiment, is still in Draft, and committed to keeping the engine-specific code isolated "so it's relatively easy to swap out later if this experiment doesn't pan out." This proposes exercising that exit, not reversing it. Two motivations. First, operator cost: edx-search now ships a Typesense backend for Course Discovery and Learning-MFE courseware search, so an operator who wants both that and Studio content search has to run two search engines. Second, licensing. ADR 0001's high-availability concern has been resolved, but only in Meilisearch's Enterprise Edition, which is BUSL-1.1 and ships as separate binaries. Self-hosted Community Edition still has no replication. That is the same class of problem ADR 0001 was written to escape when Elasticsearch left Apache 2.0. Typesense's Raft clustering is GPL-3.0 and part of the ordinary self-hosted product. Proposes a narrow interface internal to content/search with implementations for both engines, Meilisearch remaining the default. Deliberately not a general search abstraction: ADR 0001's own account of django-haystack becoming "an obstacle to upgrades" is the strongest objection to this change, and the scope is drawn to avoid repeating it. Records the parity and schema findings behind the proposal, each checked against a running Typesense 30.2 rather than inferred from documentation.
c56ddfd to
82a8bbc
Compare
|
Correcting the original version of this description, in case anyone read it before now. I had written that Meilisearch "has since shipped sharding and replication for self-hosted deployments" and that ADR 0001's high-availability concern therefore no longer applied. That is wrong. Replicated sharding requires Meilisearch Enterprise Edition v1.37+, which is BUSL-1.1 licensed and distributed as separate binaries and Docker images. Self-hosted Community Edition — MIT, and what an operator running the OSS stack would use — still has no replication. The description has been updated. The correction strengthens the case rather than weakening it, which is exactly why I want it on the record explicitly: ADR 0001's opening argument is about Elasticsearch leaving Apache 2.0 being "problematic for many Open edX operators", and Meilisearch's high-availability answer has now landed behind a commercial licence of the same kind. That is a fair thing for reviewers to weigh, and I would rather it be weighed accurately than have it quietly do work in the background off a claim I got backwards. |
| That history is a warning about a *general* search abstraction attempting to | ||
| span engines with different models. The proposal below is deliberately not that. | ||
| The interface is narrow, internal, and shaped by the operations | ||
| ``content/search`` actually performs — it is not a general-purpose search API, | ||
| carries no ambition to serve other apps, and can be changed freely because | ||
| nothing outside this app depends on it. |
There was a problem hiding this comment.
I think the bigger point here, or at least a very relevant point, is that Meilisearch and Typesense are both Algolia-style search engines and have very similar models/APIs, so abstracting across them is fairly easy. (As would be adding Algolia.)
| 3. The task-polling layer becomes conditional rather than unconditional, since | ||
| Meilisearch still needs it. It is confined to the Meilisearch | ||
| implementation. |
There was a problem hiding this comment.
It would be better to get rid of the task-polling layer completely, and move to an asynchronous API as is being discussed on #38993 . The main motivation being that the task-polling is a performance bottleneck, but also that blocking the UI is not as good as a proper async UI update would be.
Fine to leave that out of scope for any initial implementation of this ADR, but I think it's worth referencing here.
| 4. The Authoring MFE's ``search-manager`` is written against the raw | ||
| ``meilisearch`` JavaScript client rather than an InstantSearch adapter, so a | ||
| meaningful share of it must be reworked to sit behind a client seam. This is | ||
| the largest single piece of work, and larger than the backend change. |
There was a problem hiding this comment.
The search manager was originally written to use InstantSearch, however InstantSearch does not support a hierarchical menu that allows multiple selections (it only has this). I tried modifying InstantSearch to support it, but I found the code very difficult to work with (and others have concluded that multiple selections isn't really possible with InstantSearch), so we made our own implementation which was much more efficient at the cost of being tied to Meilisearch.
As far as I know, it is still an open question as to whether or not it's possible to implement the part of the Studio UI shown below (refining search with multi-select hierarchical facets) with either Typesense or Algolia. Even doing it with Meilisearch required some creative use of their advanced APIs.
must be reworked to sit behind a client seam
I think that given this is the biggest piece of work by far, it needs a more detailed plan.
There was a problem hiding this comment.
Note: one potential path forward could be dropping support for multiple selections from the UI, and switching back to InstantSearch.js. That would require product approval and also a cost-benefit analysis of its effect on bundle size.
Another path forward would be to implement our own minimal frontend abstraction layer.
Another path forward would be to implement the search logic on the backend, in the proposed python abstraction layer. Although this is clean, I prefer not do it as it can impact performance by tying up LMS worker threads merely to rewrite and pass on requests to the search engine. If we were to start using asyncio django, it could be worth a serious look though.
| The status quo. It is a defensible choice on its merits — Meilisearch works, and | ||
| its earlier HA gap has closed. It is rejected only because it obliges operators | ||
| who have already adopted Typesense elsewhere in the platform to run a second | ||
| engine indefinitely, with no path off it. |
There was a problem hiding this comment.
I don't think this particular justification makes sense. It's much easier for the few operators using Typesense (I know only of MIT) to switch to Meilisearch than it is to refactor the Studio backend and frontend to support Typesense. The "path off the second engine" exists now, and it's to stop running Typesense.
However, I'm not disagreeing with your ADR in general; I just think the only reasonable justification here would be "operators with HA requirements cannot accept Meilisearch Enterprise Edition for licensing or cost reasons", and not "someone somewhere started with Typesense so they need to be able to continue with it conveniently".
| Simpler than a pluggable backend: no interface, no indirection, one code path. | ||
| Rejected because it forces the change on every operator currently running | ||
| Meilisearch for Studio search, to solve a problem only some of them have. The | ||
| indirection in Decision 1 is the price of not doing that. |
There was a problem hiding this comment.
I would also add:
- Given the history of license changes in the past (Elasticsearch, Meilisearch, etc.), it is pragmatic to remain flexible, even if we try to encourage most people to use the same, simple solution; and
- 2U or others may wish to implement Algolia support in the future, given that they are already using it for many other parts of the platform, and since Typesense and Meilisearch are both heavily Algolia-inspired, that would be easy to do.
- In fact, Algolia is referenced in 100 different Open edX source files (see link ^), and Typesense only 24.
| Rejected for the same reasons ADR 0001 rejected it, which have not changed: the | ||
| ``edx-search`` API is a mix of abstractions and direct engine usage, and | ||
| ``content/search`` was deliberately built outside it. |
There was a problem hiding this comment.
This is where I'd like to push back the most.
If we're going to implement a new abstraction layer, I'd prefer we do it properly with something that can replace both edx_search and content/search. Not by extending edx_search but by offering a cleaner alternative which can be used for Studio search as well as any other use cases we have (mostly: forum search, course directory search), with consistent API and tooling.
|
Thanks — this is the review I was hoping for, and it has moved me on the scope question. On "the path off the second engine is to stop running Typesense"Two reasons that isn't available to us, and they are the two you identified as the reasonable justification. No high availability. Replicated sharding requires Meilisearch Enterprise Edition v1.37+, which is BUSL-1.1 and ships as separate binaries and images ( The licence shift is the same problem that prompted this engine change in the first place. ADR 0001 opens with Elasticsearch moving off Apache 2.0 to "a more restrictive license," and that being "problematic for many Open edX operators." Meilisearch has since done the same thing to the capability we need. And it is not only new features going to Enterprise Edition: S3-streaming snapshots were available in Community Edition and were reclassified as an Enterprise feature, with CE 1.25–1.30 grandfathered. A capability that was open was withdrawn. That is precisely the pattern the original decision was made to get away from, and it is a reason to keep more than one engine viable rather than to consolidate harder on this one. On the framing of Typesense as one operator's idiosyncratic choice — I would push back on that. The dichotomy is the platform's, not ours. FC-0091 added Typesense backends to Some of that work also shipped incomplete rather than merely absent: the forum backend was non-functional on any search — On scope — you've convinced meI'd rather build the thing you actually want than land a narrow layer you'd have to unpick later. Doing this as the openedx/edx-search#245 re-architecture — one surface with registered use cases (studio search, library search, learner courseware search, forum search), consistent index management and tooling — is a better shape than an interface private to One condition I'd want written into that work rather than discovered during it: Typesense needs to be a first-class target alongside Meilisearch from the start, not a backend added afterwards. That also serves your Algolia point better than a Meilisearch-shaped surface would — an abstraction designed against two engines from day one will take a third far more easily than one designed against a single engine and generalised later. This does mean my ADR is aimed at the wrong layer. I'm happy to rework it against #245's framing, or to fold it in there and close this, whichever you prefer as the venue. On keeping Django out of the search pathAgreed, and for your reason — tying up worker threads to rewrite and forward requests would be a real cost. Worth confirming that browser-direct survives the change: Typesense scoped search keys are the analogue of Meilisearch tenant tokens and share the property the original ADR valued. They're an HMAC of a parent key over a rule carrying On the frontendYou were right that I under-scoped this, and right that the line count wasn't the interesting part. Since it's the question that decides whether any of this is worth doing, I went and answered it rather than leaving it open: multi-select hierarchical faceting works on Typesense. I replicated The mapping is one-for-one, not a workaround:
Selections AND correctly across taxonomies and across levels, parent-and-child of the same branch behaves, and contradictory selections return nothing. The part I expected to be hard turned out not to arise. I went in assuming disjunctive faceting would be the obstacle, and it isn't, because Two respects where Typesense is actually the better fit for this UI:
Two things that need care, neither of them blocking:
To be clear about what this does and doesn't establish: I've proven the query mechanics, not a working UI. What remains untested is that On async indexingAgreed that relocating the task-polling layer is not as good as removing it, and I'll reference #38993 in whatever the ADR becomes. One data point for that thread, since the reporter there is on Meilisearch 1.8 and your first suggestion was to upgrade: we see the same class of problem on v1.53.1, with a |
Nice, thanks a lot for checking on that. Together with the results you shared in the other thread, I'm now kinda leaning toward making Typesense the default engine and Meilisearch the other fully-supported option, in the proposed rework. In any case, fully agree that they should both have first-class support.
@ormsbee what do you think? Personally my inclination is to create a new |
|
Closing in favour of openedx/openedx-search#1, which is the reworked version of this proposal as ADR 0001 of the new repo. @bradenmacdonald's review was right that this was aimed one layer too low. The rework follows from that:
Two things asked for in review are answered there rather than deferred: multi-select hierarchical faceting is demonstrated working on Typesense (29 checks against a real server, including that the disjunctive-faceting problem does not arise because Thanks for the review. It changed the proposal substantially and for the better. |
Description
This is an ADR proposing a direction, for discussion. No implementation accompanies it — the point is to agree (or not) on the shape before anyone writes the code.
It proposes a narrow, internal backend interface in
content/searchwith implementations for both Meilisearch and Typesense, Meilisearch remaining the default so no existing deployment changes behaviour on upgrade.Why now.
edx-searchships a Typesense backend (search/typesense.py, contributed as FC-0091) serving Course Discovery and Learning-MFE courseware search.content/searchis gated onMEILISEARCH_ENABLEDand has no engine choice. An operator who wants both therefore runs two search engines for one platform, and that cost grows as more of the platform moves onto Typesense. The two subsystems cannot share an index.courseware_index.pybuilds each document fromlocation_info, the block'sindex_dictionary(),id,start_date,content_groupsandsupplemental_fields— the stringsbreadcrumbs,block_type,publish_status,last_published,context_key,access_idandtagsappear nowhere in that module — and it indexes under apublished_onlybranch setting, while Studio authors drafts.edx-search's own module docstring says as much: "Other use cases are not supported."Why this isn't a reversal. ADR 0001 is still in Draft, adopted Meilisearch "as an experiment and to evaluate it more thoroughly," and committed to keeping the engine code isolated "so it's relatively easy to swap out later if this experiment doesn't pan out." This proposes exercising that exit. That isolation largely held, by reference count across the app:
api.py105, and essentially all real coupling;handlers.py21, of which 16 are themeilisearch_enabledsettings gate;documents.py17, all prose or themeili_id_…key-slugging helpers;tasks.py16, of which 14 areMeilisearchErrorin retry handling — the one piece of genuine coupling outsideapi.py, needing the backend to supply its own retryable exception type.High availability, and where its licence landed. ADR 0001's first stated concern was that Meilisearch "doesn't (yet) support High Availability via replication, although this is planned and under development." That has been resolved — but only in the Enterprise Edition.
Meilisearch now splits into two editions: Community Edition, MIT-licensed and free, and Enterprise Edition, licensed BUSL-1.1 and shipped as separate binaries and Docker images (
getmeili/meilisearch-enterprise). Replicated sharding requires Enterprise Edition v1.37+. A self-hosted Community Edition deployment still has no replication, and so still has no HA.That matters here more than it would elsewhere, because it is the same problem ADR 0001 was written to escape. Its opening argument against Elasticsearch is that "in 2021, the license of Elasticsearch changed from Apache 2.0 to a more restrictive license," and that this "is problematic for many Open edX operators." Meilisearch's HA answer has since arrived behind a BUSL-1.1 licence. An operator who cannot or will not take a commercial licence is where they started: Studio search on a single node with no replication path. Typesense's clustering isn't gated that way — GPL-3.0, Raft-backed replication in the ordinary self-hosted product, same
typesense/typesenseimage with a shared nodes file.One thing from ADR 0001 that should not be reused: its second concern, boolean operators in keyword search, is not a Typesense advantage. Typesense supports them in filters, as Meilisearch already does, and I established no difference in the keyword query itself.
None of this is a knock on Meilisearch's search quality. For a single-node deployment that never needs replication it remains a reasonable choice.
The strongest objection is in ADR 0001 itself, and the ADR addresses it head-on rather than hoping nobody notices:
edx-searchpreviously used django-haystack as a cross-engine abstraction, and that "became an obstacle to upgrades and efficiently utilizing Elasticsearch (the abstraction layer imposed significant limits)." That is a warning about a general abstraction spanning engines with different models. The interface proposed here is deliberately not that — it is private tocontent/search, shaped by the operations that app actually performs, offered to no other app, and changeable at will because both implementations live in-tree and nothing outside depends on it. If reviewers think that distinction doesn't hold, that is the thing to argue about, and it is worth arguing about before any code exists.Which user roles this affects: Operators (may drop one of two search engines; no action required if they don't want to), and Developers working in
content/search. No change for Course Authors or Learners.Supporting information
Feasibility was checked rather than assumed. Every mapping below was executed against a running Typesense 30.2, with documents shaped like the three library document builders and the request shapes the Authoring MFE's
search-manageractually issues — 30 of 30 checks pass:distinctAttribute→group_by+group_limit; orderedsearchableAttributes→query_by+ explicitquery_by_weights;sortableAttributes→ per-fieldsort; therankingRules"sort"-first entry needs no counterpart, since an explicitsort_byalready outranks text matching.create_index/swap_indexes/delete_index→ a collection alias repoint, which also reclaims disk immediately.delete_documents(filter=…)→ afilter_bydelete.filter_byandexpires_at, and need no API call to create. I minted one and confirmed the embedded filter is enforced server-side._wait_for_meili_taskand the polling around every write disappear on the Typesense side, since its writes are synchronous.Three findings are recorded in the ADR because they will otherwise be rediscovered the hard way:
content\..*string wildcard field — the patternedx-searchuses — rejects array sub-fields, so every container document fails to index with "field inside an array of objects must be an array type as well". Explicitstring[]overrides are needed ahead of the wildcard.edx-search's own comment flags this as a risk; for library documents it is a certainty.max_facet_valuesdefaults to 10 and caps the reported total, so a truncated facet list is not detectable from the response. Measured: 25 distincttags.level0values return 10 withtotal_values: 10; withmax_facet_values=100, 27 and 27. Left unset this would silently cut the tag filter tree off at ten branches. Meilisearch's equivalent defaults to 100.documents.pyserialises datetimes with.timestamp(), which yields a float, so those fields must be declaredfloat. Copyingedx-search'sint64coercion would reject every document.Testing instructions
Documentation only; nothing to exercise.
docutilsparses the file with no warnings. Reviewers may want to check the two claims this rests on, both of which are cheap to verify: that ADR 0001's status isDraft, and thatedx-search's Typesense backend cannot serve Studio search.Deadline
None.
Other information
Sizing, so nobody is surprised later: the backend interface is the smaller half. The Authoring MFE's
search-managertalks to the rawmeilisearchJavaScript client rather than an InstantSearch adapter —data/api.tsalone is ~590 lines of engine-shaped calls — so a substantial share of it, concentrated in the data layer, needs to sit behind a client seam. Two specifics: Typesense returnsgrouped_hitswheredistinctAttributereturns flathits, so the seam must normalise result shape; andattributesToCrophas no clean counterpart, since Typesense decides per field whether to snippet and how much context to keep, which needs a visual pass over the result cards rather than a rename.Happy to take this to a Discourse thread or a working group instead if that is the better venue for a change at this level — I opened it as an ADR PR because that is where ADR 0001 lives and what it would supersede.