Problem/Motivation
We are going through CloudFlare and we enabled their feature "Query String Sort". It's very useful, mostly for the search, because the query string parameters are always sorted alphabetically and you keep the number of combinations of the query string parameters to minimum. So for example the following requests:
?a=value&c=value&d=value&b=value
?b=value&d=value&c=value&a=value
?d=value&c=value&b=value&a=value
...
Will become one particular request
?a=value&b=value&c=value&d=value
So what happens with batch processing, if the query string sorting is enabled? Let's use real batch request:
/batch?id=18&op=do_nojs&op=do&_format=json
When sorting query string parameters, the request is turned to:
/batch?_format=json&id=18&op=do&op=do_nojs
And this results in error:
{"message":"Not acceptable format: json"}
Your larger batches are not fully processed. Why is this caused? The problem is that by reordering query string parameters Drupal thinks that operation is suddenly "do_nojs" and not "do", since it is now last one in the query string. So in the _batch_page() function in the switch it goes to "do_nojs" case and thus it ends up throwing exception in:
web/core/lib/Drupal/Core/EventSubscriber/AcceptNegotiation406.php:32
Full error message:
Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException: Not acceptable format: json in Drupal\Core\EventSubscriber\AcceptNegotiation406->onViewDetect406() (Line 32 in /web/core/lib/Drupal/Core/EventSubscriber/AcceptNegotiation406.php).
Batch should not be dependent on the order of query string parameters. Because currently with specific server configuration (or by using some external services), which reorders query string parameters, your batch processing won't work anymore.
Steps to reproduce
Add site to cloudflare with query sorting turned on and run a batch.
Proposed resolution
Make it independent of query parameter order.
Remaining tasks
Write tests.
User interface changes
N/A
Introduced terminology
N/A
API changes
N/A
Data model changes
N/A
Release notes snippet
N/A
| Comment | File | Size | Author |
|---|---|---|---|
| #105 | 15103-reroll-10.6.9.patch | 5.38 KB | sakshi@17 |
Issue fork drupal-2879023
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
hideaway commentedComment #3
hideaway commentedComment #4
hideaway commentedComment #5
hideaway commentedComment #6
hideaway commentedComment #7
hideaway commentedComment #8
hideaway commentedComment #9
hideaway commentedComment #10
hideaway commentedComment #11
hideaway commentedComment #12
hideaway commentedComment #13
john cook commentedThe problem occurs because the request handler overwrites query parameters if they appear further on in the request. This may be a bug in Symfony.
The response then returns the full page html as the 'json' request. As this is not json, the response can not be decoded when it is received by the browser — resulting in the
{"message":"Not acceptable format: json"}error.I've made a patch that replaces
op=do_nojswithop=doinstead of appending the new query parameter.This will need testing on a site with CloudFlare and the "Query String Sort" feature enabled.
Comment #14
john cook commentedComment #15
john cook commentedComment #17
blacklabel_tom commentedHi,
I can confirm I bumped into this issue on a 8.5.6 site and the attached patch has fixed this.
Marking at RTBC and requesting a back port to the 8.5 branch.
Cheers
Tom
Comment #19
john cook commentedRe-roll of patch from #13.
Comment #20
john cook commentedAfter talking to @dawehner at Drupal Europe, I've added a check and enforced that 'op=do' is added if that query parameter is not present.
I'm not sure how (or even if) this can be tested. The problem only occurs if the query parameters are rearranged, and a third party proxy is being used to do this.
Comment #21
dawehnerI would suggest to not use
~here. It is quite an unknown feature of JS. It feels like doing !== -1 would be more readable for most people, but that's maybe just me.Comment #22
john cook commentedI've changed the conditional as suggested by @dawehner in #21.
Comment #23
cms_macgyver commentedI can confirm this problem as well. To fix, I just created a Page Rule to specifically turn off Query String Sort for /batch*
Works like a charm :)
Comment #25
sluceroI can confirm as well that I encountered this issue as well on a site behind CloudFlare with default page rules in place. To help people find this issue, here's the visible page output for the error:
Adding the following custom page rule in CloudFlare immediately fixed the issue across all subdomains:
Comment #26
rbayliss commentedYep, this one bit us too - exact same cause. I think we should also fix the "finished" operation. Even if it doesn't bite us under these specific circumstances, it seems bad to have multiple "op" query parameters in general.
Comment #31
y.sa commentedThe problem is still present on Drupal 9.1.x / 9.2.x
Here is a patch for it.
Comment #32
y.sa commentedComment #33
y.sa commentedComment #34
y.sa commentedComment #35
daffie commentedThe patch style guide violations and we need testing to make sure that the fix fixes the problem and that it will stay fixed in future code updates.
Comment #41
bvoynickComment #42
bvoynickRerolled #34 as a fork for Drupal 10. (Note that one can download patches from Gitlab MRs or commits, e.g. https://git.drupalcode.org/issue/drupal-2879023/-/commit/c68aa376a7fc262886cb56d9ac762e4b06e6b174.patch - do be sure to download to your actual local/code repo, especially in the case of MRs, so that an online version doesn't get changed under you.)
Still needs tests before it can be MR/review ready.
Comment #45
nicxvan commentedI've hidden the patch since we now have a merge request, I also updated the MR to fix linting issues and updated the issue summary.
Comment #47
andypostThanks 👍 but it still needs a test
Comment #48
nicxvan commentedYes, I left that tag and left it in needs work.
I also asked for advice on writing batch tests in the contribute channel if you have any guidance.
Comment #51
oily commentedHi @m4olivei Good to see your batch test (setup) code. But I think your code could go in the existing MR !9284 and hide your new branch. Basically merge the two branches and hide the other one?
Comment #52
m4oliveiYep, thanks @oily. I'm first attempting to establish a test that fails without the patch. That's why I opened a separate branch and MR.
At the minute, it doesn't, all tests pass. My initial approach is flawed in that it operates against the current request object, which is built from the
$_GETsuper global, which PHP puts together for us at the beginning of the request. It's here, in the PHP engine, where a duplicate query string param, likeopin our case, gets de-duped. So we'll have to find some other way to simulate what Cloudflare is doing. My next thought is to use$_SERVER['REQUEST_URI'], as I think that will have the full initial query string preserved with twoopparams. I'll parse that out and modify the request object.Comment #53
m4oliveiHum, it still passed on https://git.drupalcode.org/project/drupal/-/merge_requests/13277 where I hoped that an existing test in the suite would fail. I'm fairly confident that the event listener that I added to the batch_test module does indeed simulate the behavior of CloudFlare's Query String Sort feature. I verified this by adding the same event listener to some other active module, which does trigger the error in the description. It could be that there are no tests in the test suite that exercise AJAX Batch processing, but I find that hard to believe. Going to leave this for now.
Comment #55
oily commentedHid MR !13277 as code is now merged into MR !9284
Comment #56
oily commentedI have reviewed the code. I understand that we now have an event listener to mimic what Cloudfare does but do we need to create functional code coverage in addition? The equivalent of Examples module batch module test?
Where it states in the IS,
"So what happens with batch processing, if the query string sorting is enabled? Let's use real batch request:
/batch?id=18&op=do_nojs&op=do&_format=jsonWhen sorting query string parameters, the request is turned to:
/batch?_format=json&id=18&op=do&op=do_nojs"do we not now need to create functional test coverage to simulate this? Then to test for the output, which should be in the correct form, not
/batch?_format=json&id=18&op=do&op=do_nojswhich is incorrect, but the correct form?Then can also test for the error message, which should not appear? Am I misunderstanding? Do we also need to create test batch processes (2 are defined in batch_example.module). The batch_test core test module includes core/modules/system/tests/modules/batch_test/src/BatchTestDefinitions.php where test batch operations are configured. So perhaps we need to crew new batch definition(s) similar to the ones described in the IS in there?
Comment #57
oily commentedI think the IS title is confusing. It sounds like 'Batch' ie the Drupal Batch api itself is dependent on blah blah. But the intended meaning seems to be when we/ I run a batch process, not just any batch process, but a batch process that is specifically associated with Cloudfare and also specifically utilises the query string sorting functionality that is used in connection with Cloudfare.
Understood that way it throws up the question, this query string sorting is it specific only Cloudflare and its related operations or is it some kind of standard that could be used in batch processes unconnected with Cloudflare, also? Useful to know the scope of the issue. Does it just fix a Cloudflare problem or could it solve problems related to eg various contrib modules (not just Cloudflare-related contrib modules)?
Comment #58
nicxvan commentedDrupal batch processing breaks when you use the query string sort feature in cloudflare.
Batch should not depend on the order of the parameters, just the existence.
The event subscriber is to simulate cloudflare.
We probably could do it differently but batch testing is painful.
However the test only isn't failing so test coverage isn't sufficient.
Comment #59
oily commented@nicxvan Ok, interesting. Does this mean that say the examples batch_example module were installed. It contains a couple of batch processes, would they be potentially affected if the batch_example module was added to the MR?
If the answer is 'yes' then do we need to create new test batch processes and in such a way that they are affected by the Cloudflare simulation?
Comment #60
nicxvan commentedWe really just need to trigger a batch that alphabetically sorts the parameters and successfully finishes with the patch and fails without.
Honestly if we use the event subscriber we should rename it to alpha sort and remove the company's name.
Comment #62
dries commentedI ran into this on Acquia Cloud, Drupal 11.3 and Cloudflare Enterprise. All batch operations site-wide were failing.
What I observed:
/batch?id=536&op=do_nojs&op=do&_format=json/batch?_format=json&id=536&op=do_nojsI applied the latest patch/MR from this issue to replace
op=do_nojsinstead of appending a duplicateop=doand it fixed the problem.The core problem is that
batch.jscreates URLs with duplicate query parameters:op=do_nojs&op=do. This is bad practice regardless of Cloudflare. Any reverse proxy or CDN that sorts query strings (Varnish, Fastly, Akamai, CloudFront) would trigger it.Something like
QueryStringSortSubscriberorSortedQueryStringSubscriberwould be more accurate and wouldn't tie the test to a single vendor. Otherwise this patch is simple and RTBC in my opinion.Comment #63
dries commentedLooking at this more, I think there may be a simpler and more fundamental fix available server-side.
The batch controller in
_batch_page()switches on theopquery parameter to decide what to do. Thedo_nojsoperation exists as a fallback for browsers without JavaScript. It renders an HTML progress page with a meta refresh tag that reloads the page to process the next chunk. Thedooperation is the AJAX path that processes a chunk and returns aJsonResponse.I wonder if a JSON request will ever need the
do_nojscode path? If the request has_format=json, JavaScript is making that request, and the intended operation is alwaysdo.If so, the server can simply correct for this? Something like:
Basically: if someone asks for
do_nojson a JSON request, treat it asdo, because that combination makes no sense.If that works, it would make the batch system resilient to any CDN or reverse proxy that normalizes query strings without relying on JavaScript to construct the right URL. I've not tested this, but I wanted to share the idea as it would fix the root cause more elegantly (I think).
Comment #64
dries commentedI implemented the approach proposed in #63 and tested it on my site, where I'm able to reproduce the original bug.
I applied the attached patch using
composer-patches, deployed it to my site, and verified that it works both with and without JavaScript enabled. 👍I also started reviewing the existing Batch API tests. I don't think there are any tests that cover the
opdispatch logic. As a next step, I'll try to create a MR.Comment #65
oily commented@dries re: #62-64 I was going to add tags, Needs IS update but may be worth waiting until approach #62-64 is given the thumbs up by one or two others (ce n'est pas encore un fait accompli). An MR and test will help others to review it.
If #62-64 proves good the IS title could be changed and the content edited to reflect that although Cloudflare config was how the issue was discovered, the issue is not focused either on Cloudflare or on CDN's. It is a Batch API issue.
Would it be appropriate to create a new issue based on #62-64 to fix bug in Batch API and that fixes this issue (as it was conceived prior to #62-64)?
Comment #66
dries commentedI added two basic tests. Without the fix
testDoNojsCorrectedForJsonRequestfails. It gets a render array instead of a JsonResponse becauseop=do_nojsfalls through to the HTML progress page path.testDoNojsPreservedForHtmlRequestpasses in both cases, confirming the fix doesn't break the no-JS HTML fallback.I created a merge request: https://git.drupalcode.org/issue/drupal-2879023/-/merge_requests/1. I hope I did that right.
Comment #67
oily commentedRe: #66 Running manual TEST-ONLY test. Output:
https://git.drupalcode.org/issue/drupal-2879023/-/jobs/8935363
Comment #68
oily commentedThe TEST-ONLY request leads to only 1 of the 2 tests failing.
Comment #69
dries commentedThe one passing test is intentional. As explained in comment #66, it adds missing test coverage for the path that works. I wanted to make sure there are no regressions.
Comment #70
oily commentedOk, no longer confident that my code review comment is helpful, either!
The reason for my comment is that if this issue is only triggered when the url contains the query parameter _format=json i.e. the format is json, then instead of
!== 'html'why not simply== 'json'in the if statement? I am also speculating on whether other formats like xml and api_json are in anyway relevant to this issue (beginning to think they are not) so need to be handled by the if condition?Comment #74
nicxvan commentedTurns out that updating the MR was a bit more work than I expected, this fork was missing main since it was so old so I had to push main then start from scratch, I moved @oily's comment over and pushed up the changes from @dries.
I have not fully reviewed the code yet, but it seems a bit more robust than the other approach.
Comment #75
oily commented@nicxvan I think #62:
describes the issue in more depth than so far. This stuff could be used to update the IS soon. The creative 1 passing and 1 failing test seems like it ticks the 'Needs tests' box for this issue.
I have limited experience with CDN's so not sure if you would ever get a url containing _format='xml' or suchlike. But if you do then since there only seems to be about 4 possible different values for _format in a url, hopefully all possibilities can be handled easily in the if statement. If the if statement is good as is then could be close to RTBTC?
Comment #76
oily commentedOh, and if it is possible to get _format='xml' etc in the url's then could add a couple of new test scenarios to cover it?
Perhaps I misunderstand? Is it that this bug only affects routes and their query params when a batch operation is taking place. When batch operations are taking place _format query param will only ever be equal to 'json' or 'html'?
Comment #77
dries commentedLooking at the batch route definitions in
system.routing.yml, only two formats can reach_batch_page():As a result,
_format=xmlor_format=api_jsonshould result in an error. I tested it withcurl -s "https://localhost/batch?id=1&op=do_nojs&_format=api_json", which returns a 'No route found'. Becausexml/api_jsonis blocked by the routing layer, we don't need tests here, in my opinion.This means that in our new code
!== 'html'and=== 'json'are functionally equivalent. We could change!== 'html'to=== 'json'if preferred.Comment #78
nicxvan commentedI initially added a suggestion on the MR to apply,
But on second thought I lean towards
!== htmlsince this path likely would not work with xml and api_json even if we were to add those routes at a later date.Comment #79
oily commentedThank you for #77 and #78 and for copying my comment to the MR!
Given #77 I agree
!== 'html'is fine. I just tried to resolve the 1 comment but I think only you can resolve it, @nicxvan? Seems ready for RTBTC?Comment #80
nicxvan commentedI resolved it, I actually have a couple of other things to check, I will comment back in a few minutes
Comment #81
nicxvan commentedThank you for your patience, I finally had a chance to review this properly and I have a couple of comments.
First I checked out the branch then applied the fix from the other MR to see if the tests pass with the original fix.
The tests do not pass, I think they are trying to fix the same issue, but the newer MR is better since it's server side, fixing it JS side is another round trip and get a loop if the CDN does something weird, so I think we can close that MR and focus on this one.
I don't have a site I can test this on at the moment manually though.
I have a couple of questions on the test too I'll post directly on the MR setting them to needs work for those questions which I'll post momentarily.
Finally, I wonder where we should document the fact that the op will automatically change? Maybe a change record is enough?
Comment #83
dries commentedI tried to refactor the test, but got stuck. The challenge is setting up a real batch with a valid ID that persists across requests while working with redirects.
Looking at how Core tests batch internals (e.g.
BatchKernelTest), kernel tests that directly call batch functions seem to be the norm.Maybe there is a reason batch tests don't use functional test style like
StyleSerializerEntityTest?Comment #84
dries commentedI agree it's best to drop Cloudflare from the code comments. I made that change.
Comment #85
dries commentedI looked into adding a
finishedcallback to verify the batch completes, but I don't think it applies. In progressive mode (which is the code path thatop=douses),_batch_do()calls_batch_process(), which returns[$percentage, $message, $task_message]directly. It never calls_batch_finished()...The existing
assertInstanceOf(JsonResponse::class, $response)is actually a strong assertion for what we're testing. Without the fix,op=do_nojsfalls through to thedo_nojsswitch case, which calls_batch_progress_page()and returns a render array. With the fix, it gets corrected toop=do, which calls_batch_do()and returns aJsonResponse.So, yeah, the
finishedcallback simply can't fire in this code path and I think the test might be sufficient.Comment #86
dries commentedSlightly off topic but possible important to decide the best path forward: while working on this, I started questioning whether the no-JS fallback is still worth maintaining in 2026.
Deprecating the no-JS path would simplify the batch API and eliminate bugs and complexity, including this one.
We introduced the no-JS batch API 19 years ago in May 2007, when JavaScript support in browsers was still unreliable. That has clearly changed.
I can't think of a strong reason to have a no-JS path in 2026, but I'm not up to speed on core's policy. It seems like in 2026, it’s about personal preference, not browser support. I understand people’s desire to block intrusive JavaScript (e.g blocking ads), but this is non-intrusive and could be allowed.
Either way, if we were to decide that the no-JS path can be removed, it probably does not make sense to keep investing in the current issue’s tests.
Worth creating an issue for (if one doesn't already exists)?
Comment #87
dries commentedI did some research on #86 from a taxi to the airport after DrupalCon Chicago (so not ideal), but I can’t find an official Drupal Core policy requiring a no-JS fallback path.
In practice, it appears that several Core subsystems (e.g. Media Library, Toolbar) require JavaScript and don’t currently provide a fallback. See #3396174: The toolbar should be usable without JS, for example.
I understand this is slightly off-topic, so I’ll stop here, and can create a new issue later, if desired.
Comment #88
nicxvan commentedLooking at the code that seems right, something is still nagging me in the back of my mind but I don't see anything so I don't think this is worth holding up further. I might ask in slack.
That is an interesting question, I would assume batches run through drush use a no js route, so we'd probably keep them for that alone.
Either way that is a separate issue for sure, John Cook is the subsystem maintainer for batches, we could ask him.
PS I love this:
Comment #89
john cook commentedThank you all for working in this.
I’m happy to proceed with the current changes.
I’ve looked through the changes and everything looks good.
Whether we need a no-JS fallback is not for me to decide. But without official policy, I’d like to keep it. If a policy is created then we’d need a separate issue for removing the fallback.
I’ve checked the source of Drush (https://github.com/drush-ops/drush/blob/14.x/includes/batch.inc) and Drush doesn’t use the changed code. It has its own batch processing system.
A separate issue might be to change
batch.jsso that it doesn’t append ops, but replaces the existing values. But that is not needed here.I’m leaving as “needs review” as manual testing is still required.
Comment #90
oily commentedComment #91
dries commentedRe #88: It seems Drush has its own batch implementation: see drush/includes/batch.inc. Looking at that code for a bit (and for the first time ever), it seems Drush uses parts of Core's batch API but replaces the HTTP orchestration (
_batch_page(),_batch_do(), thedo_nojsmeta refresh path) with its own process spawning. That makes sense.Interestingly, grepping around, it looks like
BrowserTestBasefunctional tests rely on the no-JS meta refresh path to drive batches. Not sure why, but as a starting point, see UiHelperTrait::checkForMetaRefresh() and it's caller around line 117).So: it would probably not impact Drush, but likely impact browser tests. 😬
Comment #92
dries commentedRe #89: I have been using the latest patch in production. I understand and support the need for more testers, but wanted to share that it has been working for me.
Comment #93
nicxvan commentedI will try test this manually, I will create a custom module locally and call _batch_populate_queue with a batch from it, then use curl to trigger in different parameter orders.
Edit: I'm not having much luck unfortunately, I got the queue populated using the code in the test in a custom module, but trying to trigger it without the batch.js is proving a bit more involved than I have time for at the moment.
Comment #94
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #96
dries commentedComment #97
nicxvan commentedYeah I wasn't sure how to move this forward, I wasn't able to manually test as requested, but the code looks right and it's tested, I just couldn't trigger it manually with curl.
Comment #99
alexpottThis fix feels like it is in the wrong layer. I think we can use vanilla JS so that we only send one op.
I.e. let's do
I've tested this locally and it works a treat.
Comment #100
alexpottHere's why I yhink #99 is the correct fix. The additional op is added in batch.js - it's not the code we're chaging in the MR that was rtbc the nojs thing is always present on the first response to start the batch. It's the JS implementation that has to be responsible for changing it.
Comment #102
alexpottI've pushed a test up that covers the change in suggested in #99.
Comment #103
smustgrave commentedWhich MR should be reviewed?
Comment #104
alexpott@smustgrave in my mind MR 15594 is the correct fix here as it fixes the code that is responsible for adding the duplicate op query string. It could be argued that the other code makes the system a bit more robust if it is used incorrectly but I think just not using the system incorrectly is the better fix.
Comment #105
sakshi@17 commentedRerolled the patch from MR !15103 against Drupal 10.6.9.
The original patch fails to apply because the _batch_populate_queue() function in form.inc has moved from line ~582 to line ~1001, causing a context mismatch on the @param $set_id hunk.