-
Notifications
You must be signed in to change notification settings - Fork 578
Improve indexing command #2133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Improve indexing command #2133
Changes from all commits
a223e49
5ecc10f
d62ff58
8ec2178
e2c971f
f6b4c8c
327f528
69353a8
d305eea
3627da2
6595f4b
f71cbe0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,65 @@ | ||||||||||||||||||
| # Index Command | ||||||||||||||||||
|
|
||||||||||||||||||
| The `index` management command is used to index documents to the remote search indexer. | ||||||||||||||||||
|
|
||||||||||||||||||
| It sends an asynchronous task to the Celery worker. | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misleading top-level description. The command runs synchronously by default; it only dispatches to Celery when 📝 Suggested fix-It sends an asynchronous task to the Celery worker.
+By default, it indexes documents synchronously. Pass `--async` to dispatch the work to a Celery worker instead.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| ## Usage | ||||||||||||||||||
|
|
||||||||||||||||||
| ### Make Command | ||||||||||||||||||
|
|
||||||||||||||||||
| ```bash | ||||||||||||||||||
| # Basic usage with defaults | ||||||||||||||||||
| make index | ||||||||||||||||||
|
|
||||||||||||||||||
| # With custom parameters | ||||||||||||||||||
| make index args="--batch-size 100 --lower-time-bound 2024-01-01T00:00:00 --upper-time-bound 2026-01-01T00:00:00" | ||||||||||||||||||
|
|
||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ### Command line | ||||||||||||||||||
|
|
||||||||||||||||||
| ```bash | ||||||||||||||||||
| python manage.py index \ | ||||||||||||||||||
| --lower-time-bound "2024-01-01T00:00:00" \ | ||||||||||||||||||
| --upper-time-bound "2024-01-31T23:59:59" \ | ||||||||||||||||||
| --batch-size 200 \ | ||||||||||||||||||
| --async_mode | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
Comment on lines
+22
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrong flag name: use The argparse argument name is 📝 Suggested fix python manage.py index \
--lower-time-bound "2024-01-01T00:00:00" \
--upper-time-bound "2024-01-31T23:59:59" \
--batch-size 200 \
- --async_mode
+ --async📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| ### Django Admin | ||||||||||||||||||
|
|
||||||||||||||||||
| The command is available in the Django admin interface: | ||||||||||||||||||
|
|
||||||||||||||||||
| 1. Go to `/admin/core/run-indexing/`, you arrive at the "Run Indexing Command" page | ||||||||||||||||||
| 3. Fill in the form with the desired parameters | ||||||||||||||||||
| 4. Click **"Run Indexing Command"** | ||||||||||||||||||
|
Comment on lines
+34
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Broken ordered list numbering and inconsistent button label.
📝 Suggested fix-1. Go to `/admin/core/run-indexing/`, you arrive at the "Run Indexing Command" page
-3. Fill in the form with the desired parameters
-4. Click **"Run Indexing Command"**
+1. Go to `/admin/core/run-indexing/`, you arrive at the "Run Indexing Command" page
+2. Fill in the form with the desired parameters
+3. Click **"Run Indexing"**📝 Committable suggestion
Suggested change
🧰 Tools🪛 markdownlint-cli2 (0.22.0)[warning] 35-35: Ordered list item prefix (MD029, ol-prefix) [warning] 36-36: Ordered list item prefix (MD029, ol-prefix) 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| ## Parameters | ||||||||||||||||||
|
|
||||||||||||||||||
| ### `--batch-size` | ||||||||||||||||||
| - **type:** Integer | ||||||||||||||||||
| - **default:** `settings.SEARCH_INDEXER_BATCH_SIZE` | ||||||||||||||||||
| - **description:** Number of documents to process per batch. Higher values may improve performance but use more memory. | ||||||||||||||||||
|
|
||||||||||||||||||
| ### `--lower-time-bound` | ||||||||||||||||||
| - **optional**: true | ||||||||||||||||||
| - **type:** ISO 8601 datetime string | ||||||||||||||||||
| - **default:** `None` | ||||||||||||||||||
| - **description:** Only documents updated after this date will be indexed. | ||||||||||||||||||
|
|
||||||||||||||||||
| ### `--upper-time-bound` | ||||||||||||||||||
| - **optional**: true | ||||||||||||||||||
| - **type:** ISO 8601 datetime string | ||||||||||||||||||
| - **default:** `None` | ||||||||||||||||||
| - **description:** Only documents updated before this date will be indexed. | ||||||||||||||||||
|
|
||||||||||||||||||
| ## `--async_mode` | ||||||||||||||||||
| - **type:** Boolean flag | ||||||||||||||||||
| - **default:** `False` | ||||||||||||||||||
| - **description:** Runs asynchronously is async_mode==True. | ||||||||||||||||||
|
Comment on lines
+57
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Section heading uses wrong flag name and has a grammar/typo issue; also missing heading level. Several problems on these lines:
📝 Suggested fix-## `--async_mode`
+### `--async`
- **type:** Boolean flag
- **default:** `False`
-- **description:** Runs asynchronously is async_mode==True.
+- **description:** When set, dispatches the indexing job to a Celery worker instead of running it synchronously.📝 Committable suggestion
Suggested change
🧰 Tools🪛 markdownlint-cli2 (0.22.0)[warning] 57-57: Headings should be surrounded by blank lines (MD022, blanks-around-headings) 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| ## Crash Safe Mode | ||||||||||||||||||
|
|
||||||||||||||||||
| The command saves the updated.at of the last document of each successful batch into the `bulk-indexer-checkpoint` cache variable. | ||||||||||||||||||
| If the process crashes, this value can be used as `lower-time-bound` to resume from the last successfully indexed document. | ||||||||||||||||||
|
Comment on lines
+64
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo: The field is 📝 Suggested fix-The command saves the updated.at of the last document of each successful batch into the `bulk-indexer-checkpoint` cache variable.
+The command saves the `updated_at` of the last document of each successful batch into the `bulk-indexer-checkpoint` cache variable.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,55 @@ | ||
| """Admin classes and registrations for core app.""" | ||
|
|
||
| from datetime import datetime | ||
|
|
||
| from django.contrib import admin, messages | ||
| from django.contrib.auth import admin as auth_admin | ||
| from django.shortcuts import redirect | ||
| from django.core.management import call_command | ||
| from django.http import HttpRequest | ||
| from django.shortcuts import redirect, render | ||
| from django.utils.translation import gettext_lazy as _ | ||
|
|
||
| from treebeard.admin import TreeAdmin | ||
|
|
||
| from core import models | ||
| from core.forms import RunIndexingForm | ||
| from core.tasks.user_reconciliation import user_reconciliation_csv_import_job | ||
|
|
||
|
|
||
| # Customize the default admin site's get_app_list method | ||
| _original_get_app_list = admin.site.get_app_list | ||
|
|
||
|
|
||
| def custom_get_app_list(self, request, app_label=None): | ||
| """Add custom commands to the app list.""" | ||
| app_list = _original_get_app_list(request, app_label) | ||
|
|
||
| # Add Commands app with Run Indexing command | ||
| commands_app = { | ||
| "name": _("Commands"), | ||
| "app_label": "commands", | ||
| "app_url": "#", | ||
| "has_module_perms": True, | ||
| "models": [ | ||
| { | ||
| "name": _("Run indexing"), | ||
| "object_name": "RunIndexing", | ||
| "admin_url": "/admin/core/run-indexing/", | ||
| "view_only": False, | ||
| "add_url": None, | ||
| "change_url": None, | ||
| } | ||
| ], | ||
| } | ||
|
|
||
| app_list.append(commands_app) | ||
| return app_list | ||
|
|
||
|
|
||
| # Monkey-patch the admin site | ||
| admin.site.get_app_list = custom_get_app_list.__get__(admin.site, admin.AdminSite) | ||
|
|
||
|
|
||
| @admin.register(models.User) | ||
| class UserAdmin(auth_admin.UserAdmin): | ||
| """Admin class for the User model""" | ||
|
|
@@ -227,3 +266,43 @@ class InvitationAdmin(admin.ModelAdmin): | |
| def save_model(self, request, obj, form, change): | ||
| obj.issuer = request.user | ||
| obj.save() | ||
|
|
||
|
|
||
| def run_indexing_view(request: HttpRequest): | ||
| """Custom admin view for running indexing commands.""" | ||
| if request.method == "POST": | ||
| form = RunIndexingForm(request.POST) | ||
| if form.is_valid(): | ||
| call_command( | ||
| "index", | ||
| batch_size=int(request.POST.get("batch_size")), | ||
| lower_time_bound=convert_to_isoformat( | ||
| request.POST.get("lower_time_bound") | ||
| ), | ||
| upper_time_bound=convert_to_isoformat( | ||
| request.POST.get("upper_time_bound") | ||
| ), | ||
| async_mode=True, | ||
| ) | ||
|
Comment on lines
+274
to
+286
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use After ♻️ Suggested fix if form.is_valid():
+ lower = form.cleaned_data.get("lower_time_bound")
+ upper = form.cleaned_data.get("upper_time_bound")
call_command(
"index",
- batch_size=int(request.POST.get("batch_size")),
- lower_time_bound=convert_to_isoformat(
- request.POST.get("lower_time_bound")
- ),
- upper_time_bound=convert_to_isoformat(
- request.POST.get("upper_time_bound")
- ),
+ batch_size=form.cleaned_data["batch_size"],
+ lower_time_bound=lower.isoformat() if lower else None,
+ upper_time_bound=upper.isoformat() if upper else None,
async_mode=True,
)With this, 🤖 Prompt for AI Agents |
||
| messages.success(request, _("Indexing triggered!")) | ||
| else: | ||
| messages.error(request, _("Please correct the errors below.")) | ||
| else: | ||
| form = RunIndexingForm() | ||
|
Comment on lines
+271
to
+291
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: the view has no authentication or permission check.
🔒 Suggested fix+from django.contrib.admin.views.decorators import staff_member_required
+
+@staff_member_required
def run_indexing_view(request: HttpRequest):
"""Custom admin view for running indexing commands."""Consider also requiring a more specific permission (e.g. 🤖 Prompt for AI Agents
Comment on lines
+287
to
+291
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. POST path should redirect (PRG) to avoid resubmission on refresh. Rendering the template directly after a successful POST means a browser refresh will re-dispatch the Celery task. Use ♻️ Suggested fix if form.is_valid():
call_command(...)
messages.success(request, _("Indexing triggered!"))
+ return redirect("run_indexing")
else:
messages.error(request, _("Please correct the errors below."))🤖 Prompt for AI Agents |
||
|
|
||
| return render( | ||
| request=request, | ||
| template_name="runindexing.html", | ||
| context={ | ||
| **admin.site.each_context(request), | ||
| "title": "Run Indexing Command", | ||
| "form": form, | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| def convert_to_isoformat(value: str) -> str | None: | ||
| """Convert datetime-local input to ISO format.""" | ||
| if value: | ||
| return datetime.fromisoformat(value).isoformat() | ||
| return None | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||||||||||||||||||||||||||||||||
| """Forms for the core app.""" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from django import forms | ||||||||||||||||||||||||||||||||||||
| from django.conf import settings | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| class RunIndexingForm(forms.Form): | ||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||
| Form for running the indexing process. | ||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| batch_size = forms.IntegerField( | ||||||||||||||||||||||||||||||||||||
| min_value=1, | ||||||||||||||||||||||||||||||||||||
| initial=settings.SEARCH_INDEXER_BATCH_SIZE, | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| lower_time_bound = forms.DateTimeField( | ||||||||||||||||||||||||||||||||||||
| required=False, widget=forms.TextInput(attrs={"type": "datetime-local"}) | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| upper_time_bound = forms.DateTimeField( | ||||||||||||||||||||||||||||||||||||
| required=False, widget=forms.TextInput(attrs={"type": "datetime-local"}) | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def clean(self): | ||||||||||||||||||||||||||||||||||||
| """Override clean to validate time bounds.""" | ||||||||||||||||||||||||||||||||||||
| cleaned_data = super().clean() | ||||||||||||||||||||||||||||||||||||
| self.check_time_bounds() | ||||||||||||||||||||||||||||||||||||
| return cleaned_data | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def check_time_bounds(self): | ||||||||||||||||||||||||||||||||||||
| """Validate that lower_time_bound is before upper_time_bound.""" | ||||||||||||||||||||||||||||||||||||
| lower_time_bound = self.cleaned_data.get("lower_time_bound") | ||||||||||||||||||||||||||||||||||||
| upper_time_bound = self.cleaned_data.get("upper_time_bound") | ||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||
| lower_time_bound | ||||||||||||||||||||||||||||||||||||
| and upper_time_bound | ||||||||||||||||||||||||||||||||||||
| and lower_time_bound > upper_time_bound | ||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||
| raise forms.ValidationError( | ||||||||||||||||||||||||||||||||||||
| "Lower time bound must be before upper time bound." | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Attach the cross-field error to a specific field for better admin UX. Raising from ♻️ Suggested refactor if (
lower_time_bound
and upper_time_bound
and lower_time_bound > upper_time_bound
):
- raise forms.ValidationError(
- "Lower time bound must be before upper time bound."
- )
+ self.add_error(
+ "upper_time_bound",
+ _("Upper time bound must be after lower time bound."),
+ )Also consider wrapping the message in 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,12 +4,16 @@ | |
|
|
||
| import logging | ||
| import time | ||
| from datetime import datetime | ||
|
|
||
| from django.conf import settings | ||
| from django.core.management.base import BaseCommand, CommandError | ||
|
|
||
| from core import models | ||
| from core.services.search_indexers import get_document_indexer | ||
| from core.tasks.search import batch_document_indexer_task | ||
|
|
||
| logger = logging.getLogger("docs.search.bootstrap_search") | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
|
|
@@ -24,9 +28,32 @@ def add_arguments(self, parser): | |
| action="store", | ||
| dest="batch_size", | ||
| type=int, | ||
| default=50, | ||
| default=settings.SEARCH_INDEXER_BATCH_SIZE, | ||
| help="Indexation query batch size", | ||
| ) | ||
| parser.add_argument( | ||
| "--lower-time-bound", | ||
| action="store", | ||
| dest="lower_time_bound", | ||
| type=datetime.fromisoformat, | ||
| default=None, | ||
| help="DateTime in ISO format. Only documents updated after this date will be indexed", | ||
| ) | ||
| parser.add_argument( | ||
| "--upper-time-bound", | ||
| action="store", | ||
| dest="upper_time_bound", | ||
| type=datetime.fromisoformat, | ||
| default=None, | ||
| help="DateTime in ISO format. Only documents updated before this date will be indexed", | ||
| ) | ||
| parser.add_argument( | ||
| "--async", | ||
| action="store_true", | ||
| dest="async_mode", | ||
| default=False, | ||
| help="Whether to execute indexing asynchronously in a Celery task (default: False)", | ||
| ) | ||
|
|
||
| def handle(self, *args, **options): | ||
| """Launch and log search index generation.""" | ||
|
|
@@ -35,18 +62,37 @@ def handle(self, *args, **options): | |
| if not indexer: | ||
| raise CommandError("The indexer is not enabled or properly configured.") | ||
|
|
||
| logger.info("Starting to regenerate Find index...") | ||
| start = time.perf_counter() | ||
| batch_size = options["batch_size"] | ||
| if options["async_mode"]: | ||
| batch_document_indexer_task.apply_async( | ||
| kwargs={ | ||
| "lower_time_bound": options["lower_time_bound"], | ||
| "upper_time_bound": options["upper_time_bound"], | ||
| "batch_size": options["batch_size"], | ||
| "crash_safe_mode": True, | ||
| }, | ||
| ) | ||
| logger.info( | ||
| "Document indexing task sent to worker", | ||
| ) | ||
|
Comment on lines
+65
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No error handling around If the broker is unreachable or the task fails to enqueue (e.g., 🛠️ Suggested fix if options["async_mode"]:
- batch_document_indexer_task.apply_async(
- kwargs={
- "lower_time_bound": options["lower_time_bound"],
- "upper_time_bound": options["upper_time_bound"],
- "batch_size": options["batch_size"],
- "crash_safe_mode": True,
- },
- )
- logger.info(
- "Document indexing task sent to worker",
- )
+ try:
+ batch_document_indexer_task.apply_async(
+ kwargs={
+ "lower_time_bound": options["lower_time_bound"],
+ "upper_time_bound": options["upper_time_bound"],
+ "batch_size": options["batch_size"],
+ "crash_safe_mode": True,
+ },
+ )
+ except Exception as err:
+ raise CommandError("Unable to dispatch indexing task") from err
+ logger.info("Document indexing task sent to worker")🤖 Prompt for AI Agents |
||
| else: | ||
| logger.info("Starting to regenerate Find index...") | ||
| start = time.perf_counter() | ||
|
|
||
| try: | ||
| count = indexer.index(batch_size=batch_size) | ||
| except Exception as err: | ||
| raise CommandError("Unable to regenerate index") from err | ||
| try: | ||
| count = indexer.index( | ||
| queryset=models.Document.objects.filter_updated_at( | ||
| lower_time_bound=options["lower_time_bound"], | ||
| upper_time_bound=options["upper_time_bound"], | ||
| ), | ||
| batch_size=options["batch_size"], | ||
| crash_safe_mode=True, | ||
| ) | ||
| except Exception as err: | ||
| raise CommandError("Unable to regenerate index") from err | ||
|
|
||
| duration = time.perf_counter() - start | ||
| logger.info( | ||
| "Search index regenerated from %d document(s) in %.2f seconds.", | ||
| count, | ||
| duration, | ||
| ) | ||
| duration = time.perf_counter() - start | ||
| logger.info( | ||
| "Search index regenerated from %d document(s) in %.2f seconds.", | ||
| count, | ||
| duration, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -859,6 +859,38 @@ def annotate_user_roles(self, user): | |
| user_roles=models.Value([], output_field=output_field), | ||
| ) | ||
|
|
||
| def filter_updated_at(self, lower_time_bound=None, upper_time_bound=None): | ||
|
mascarpon3 marked this conversation as resolved.
|
||
| """ | ||
| Filter documents by update_at. | ||
|
|
||
| Args: | ||
| lower_time_bound (datetime, optional): | ||
| Keep documents updated after this timestamp. | ||
| upper_time_bound (datetime, optional): | ||
| Keep documents updated before this timestamp. | ||
|
|
||
| Returns: | ||
| QuerySet: Filtered queryset ready for indexation. | ||
| """ | ||
| conditions = models.Q() | ||
| if lower_time_bound and upper_time_bound: | ||
| conditions = models.Q( | ||
| updated_at__gte=lower_time_bound, updated_at__lte=upper_time_bound | ||
| ) | models.Q( | ||
| ancestors_deleted_at__gte=lower_time_bound, | ||
| ancestors_deleted_at__lte=upper_time_bound, | ||
| ) | ||
| elif lower_time_bound: | ||
| conditions = models.Q(updated_at__gte=lower_time_bound) | models.Q( | ||
| ancestors_deleted_at__gte=lower_time_bound | ||
| ) | ||
| elif upper_time_bound: | ||
| conditions = models.Q(updated_at__lte=upper_time_bound) | models.Q( | ||
| ancestors_deleted_at__lte=upper_time_bound | ||
| ) | ||
|
|
||
| return self.filter(conditions) | ||
|
Comment on lines
+875
to
+892
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restored documents can be missed by bounded re-indexing. This filter only sees the current 🛠️ Suggested directionEither bump - self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
+ self.updated_at = timezone.now()
+ self.save(update_fields=["deleted_at", "ancestors_deleted_at", "updated_at"])Also ensure descendant restore updates have a timestamp that 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)): | ||
| """ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move this entry to
[Unreleased].This PR is still open and v4.8.3 is already dated
2026-03-23, so adding it under that release will make the changelog inaccurate for the next release.📝 Proposed changelog placement
📝 Committable suggestion
🤖 Prompt for AI Agents