Skip to content

fix: bound how long content/search waits on Meilisearch document writes - #39240

Open
blarghmatey wants to merge 2 commits into
openedx:masterfrom
mitodl:tmacey/bound-meili-task-wait
Open

blarghmatey wants to merge 2 commits into
openedx:masterfrom
mitodl:tmacey/bound-meili-task-wait

Conversation

@blarghmatey

@blarghmatey blarghmatey commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

_wait_for_meili_task polls Meilisearch until a task finishes. Meilisearch applies tasks one at a time in the order they were enqueued, across every index on the instance, so a one-document write waits behind whatever is ahead of it in the queue. Several library handlers in handlers.py run their search task with .apply(), so that wait happens inside the Studio request, and when the queue is backed up the author gets a 504 for a change that was saved. #39072 bounded this for library block upserts with SYNC_INDEX_WAIT_TIMEOUT (3s). The other inline writes still wait with no deadline: block deletes, library create and update, and collection and container writes and deletes.

This extends #39072's bound to the rest of the inline writes. The search signal handlers now pass SYNC_INDEX_WAIT_TIMEOUT (3s) as wait_timeout on every write they make inline, with .apply() or a direct call: block and container deletes, library create and update, collection and container upserts, and the tag, collection and container association upserts. wait_timeout is threaded through those tasks and API functions down to _update_index_docs, _delete_index_doc and _delete_documents, which pass it to the timeout that #39072 added to _wait_for_meili_task. When the timeout runs out the helper logs and returns; the write stays in Meilisearch's queue and is applied when Meilisearch reaches it. There is no new setting.

The tasks the handlers dispatch with .delay() (course block upserts and deletes, background collection updates, course reindex and delete) don't pass wait_timeout and keep waiting until Meilisearch is done, so a write that fails there is still raised and autoretry_for still retries it. Index creation, rebuilds and settings updates call _wait_for_meili_task directly and keep waiting until done.

The limit is per write, not per request. A library change with fewer than five affected entities runs send_change_events_for_modified_entities inline, and each entity's handler makes its own write, so the worst case is a few multiples of 3s rather than one 3s budget. I went with a per-write limit because a shared per-request budget needs request-scoped state threaded through the Celery tasks, and the per-write version is enough to stop the 504s.

The cost is the same one #39072 accepts for block upserts: a write that is not waited for is not visible to the Authoring MFE's refetch until Meilisearch applies it, and a write that fails after the timeout is not raised. When five or more entities change, send_change_events_for_modified_entities runs in a worker, and the search handlers it triggers bound their writes there too, as the block upsert handlers already do.

The index split in #39103 does not remove this wait. It separates documents, but studio_content and studio_library_content share one Meilisearch task queue ("Enqueued tasks are processed one at a time, following the order in which they were requested", and a batch only groups tasks on the same index, per https://www.meilisearch.com/docs/learn/async/asynchronous_operations), so library writes still queue behind course writes.

This is narrower than the async API discussed in #38993, which would remove the polling altogether. It bounds the existing behavior until that lands. It does not overlap #39042, which bounds Celery retries and time limits but cannot bound the eager path, since soft_time_limit is not enforced under .apply().

Roles affected: Course Author (no 504 on the library writes above).

Supporting information

What we measured on courses.learn.mit.edu (Meilisearch 1.53.1, single instance, after #39103):

  • studio_content holds 1,588,735 documents and studio_library_content 1,226.
  • On 2026-10-14 between 14:50 and 15:00 UTC, four POST /api/libraries/v2/<lib>/blocks/ requests got a 504 from our CDN at 60.0s. The traces for two of them show the CMS returned 200 after 140.4s and 141.9s, and 140.37s of the first was the eager upsert_library_block_index_doc span.
  • meilisearch_task_queue_latency_seconds was 76-201s for that whole window and peaked at 439s at 15:12.
  • Over the 7 days to 2026-10-15, 4 of 18 component creates on that instance returned 504.

Those 504s were component creates, which #39072 now covers. The other inline writes wait on the same queue; I have not measured them separately.

Testing instructions

TestDocumentWriteWaitTimeout (test_api.py) covers the three write helpers passing wait_timeout through and waiting indefinitely without it. TestInlineWritesBoundTheWait (test_handlers.py) covers each inline handler passing SYNC_INDEX_WAIT_TIMEOUT, and the background collection update going through .delay() without it. test_api.py and test_handlers.py pass locally (67 tests); 8 of the 9 new handler tests fail against the handlers without this change.

To reproduce against a running Studio, the queue has to be backed up, not unreachable:

  1. Enable Meilisearch for Studio.
  2. Enqueue a large batch against the course index (e.g. run ./manage.py cms reindex_studio on a course-heavy instance), so the queue is busy for minutes.
  3. Delete a component in a V2 library while that runs. The request returns in roughly 3s and the CMS logs Stopped waiting for Meilisearch task <uid> after 3.0s. Without this change, the same request blocks until the batch ahead of it finishes.
  4. Once the queue drains, the component is gone from the library listing.

Deadline

None.

https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 15, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @blarghmatey!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform-oncall.

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 approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To 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:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where 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:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Oct 15, 2026
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Oct 22, 2026
@mphilbrick211 mphilbrick211 added the needs reviewer assigned PR needs to be (re-)assigned a new reviewer label Oct 22, 2026
@mphilbrick211

Copy link
Copy Markdown

@blarghmatey flagging some conflicts have popped up!

openedx#39072 bounded the wait for inline library block upserts with
SYNC_INDEX_WAIT_TIMEOUT. The other incremental writes still poll with no
deadline, and the library handlers run several of them inline with
.apply(): block deletes, library updates, and collection and container
writes. Meilisearch applies tasks one at a time in enqueue order across
every index, so a one-document write waits behind whatever is queued
ahead of it, and a backed-up queue turns those Studio requests into
gateway timeouts for changes that were saved.

This adds MEILISEARCH_WRITE_WAIT_TIMEOUT (default None, which keeps the
current behavior). _update_index_docs, _delete_documents and
_delete_index_doc use it when the caller does not pass its own
wait_timeout, so the library block upserts keep SYNC_INDEX_WAIT_TIMEOUT.
Index creation, rebuilds and settings updates keep waiting until done.

Claude-Session: https://claude.ai/code/session_01JxJWMHVXsuXaChbeEpKT8t
@blarghmatey
blarghmatey force-pushed the tmacey/bound-meili-task-wait branch from bf3e3cd to bfecc15 Compare October 29, 2026 13:14
@bradenmacdonald

Copy link
Copy Markdown
Contributor

yeah, I realize now that splitting the index in two doesn't help as much as I hoped, given Meili's shared task queue architecture :/

Comment thread cms/envs/common.py Outdated
# gateway's upstream timeout. A write that is not waited for is still applied later, but until then the Authoring
# MFE's refetch of the library listing will not show it, and if it fails the failure is not raised (so a Celery task
# making it is not retried).
MEILISEARCH_WRITE_WAIT_TIMEOUT = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this setting to be separate from SYNC_INDEX_WAIT_TIMEOUT ? And why don't we set a default timeout, rather than None?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald They bound the same wait (the _wait_for_meili_task poll after a document write) and differ in where they apply. SYNC_INDEX_WAIT_TIMEOUT is a constant in api.py that only the two inline block-upsert handlers pass. MEILISEARCH_WRITE_WAIT_TIMEOUT sat inside _update_index_docs/_delete_index_doc/_delete_documents, so it also reached every .delay() task, and that's why it defaulted to None: past the deadline a failed write is not raised, so autoretry_for=(MeilisearchError, ConnectionError) would stop retrying worker writes like upsert_xblock_index_doc. That's an argument for scoping the bound, not for a second setting, so I've dropped the setting. The search handlers now pass SYNC_INDEX_WAIT_TIMEOUT (3s) on every write they make inline (block and container deletes, library create/update, collection and container upserts, and the tag/collection/container association upserts), threaded through as wait_timeout the same way #39072 did, and the .delay() tasks keep waiting until Meilisearch is done. It's still per write: a change that send_change_events_for_modified_entities handles inline (fewer than 5 entities) can make several of these writes, so the worst case is a few multiples of 3s rather than one 3s budget.

@mphilbrick211 mphilbrick211 removed the needs reviewer assigned PR needs to be (re-)assigned a new reviewer label Nov 4, 2026
@mphilbrick211 mphilbrick211 moved this from Ready for Review to In Eng Review in Contributions Nov 4, 2026
MEILISEARCH_WRITE_WAIT_TIMEOUT applied inside the document write helpers,
so a non-None default would also have bounded the .delay() tasks. A
worker write that fails after the deadline is not raised, so
autoretry_for would stop retrying it, which is why the setting defaulted
to None and left the 504s in place unless an operator set it.

This drops the setting and scopes the bound to where openedx#39072 put it: the
search signal handlers pass SYNC_INDEX_WAIT_TIMEOUT to every write they
make inline (block and container deletes, library create and update,
collection and container upserts, and the tag/collection/container
association upserts), threaded through the tasks and api functions as
wait_timeout. The tasks dispatched with .delay() keep waiting until
Meilisearch is done.

Claude-Session: https://claude.ai/code/session_01FGZebDFmRXH4ru7BpiDzjH

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: In Eng Review

Development

Successfully merging this pull request may close these issues.

4 participants