Closed (fixed)
Project:
Webform
Version:
7.x-4.x-dev
Component:
Code
Priority:
Normal
Category:
Bug report
Assigned:
Unassigned
Reporter:
Created:
3 Apr 2015 at 18:47 UTC
Updated:
26 Apr 2015 at 12:44 UTC
Jump to comment: Most recent, Most recent file
Comments
Comment #1
pixlkat commentedThis patch saves the full list to the batch sandbox context and sets $options['sids'] to just the sids to retrieve on this batch run.
Comment #2
pixlkat commentedComment #3
danchadwick commentedMmmmm, not saying that something isn't broken, but I'm pretty sure this isn't the right fix. It is pretty complicated code, so without stepping through various options, I may be wrong, but...
Thanks for the report.
Looking at webform_download_sids() it appears to use a pager query and $_GET['page'] to communicate which batch of sids should be return. I'm pretty sure that the intention is to never load every sid.
I do see some code in webform_results_download_range_validate() that loads the sids for the specified range into the the $form_state, and then code in the submit handler webform_results_download_form_submit that for all other range_types but 'all' loads the sids too.
This surprises me because it would seem to go against just using the pager to get the right sids for that batch. I don't understand why it is implemented like this. 'range_serial' was added at one point and that may be when the validation handler got added.
What I *think* should be happening is that the list of sids should never be passed in the sandbox, but rather the pager query should be getting the right batch.
What download range options were you using? Let's see if we can fix the issue plus make it work correctly. With very large webforms, downloading all the sids may well be a time or memory problem.
Comment #4
pixlkat commentedI started out testing with a webform that I created and programmatically added 100,000 submissions. Trying to download the whole lot died rather spectacularly with an execution time error. When I tried download a subset using "download range options" and used the "all submissions from ___ to ___" option setting it to less than the 10,000 batch size gave me what I would expect. If I set it to something more than that (say 12,000), I ended up with a file with two set of all 12,000 records (I had to increase my php max execution time to get it to return anything over 10,000).
I set the max batch size to 50 and chose 1-150 as my number of submissions to download (so 151 total submissions). The $options['sids'] array was set to 151 items the first time through the batch and that was getting stored in $options['sids'] and passed to webform_results_download_rows(). At no time did I ever see anything just grabbing "batch_size" of sids to pass to this function.
I am happy to reset back to the original version of this code and step through what is happening in the form submit function and is actually getting set before beginning the batch process.
Comment #5
pixlkat commentedYou are exactly right -- in all cases except for "all" the list of sids is preloaded into the batch so webform_download_sids() is never called after the batch size has been defined and it operates on the entire list every time. Removing the lines from the submit handler causes the sids to be loaded in the batch process as one would expect. I've attached a new patch for that. Thanks.
Comment #6
pixlkat commentedComment #7
danchadwick commentedThere's lots that I don't like looking at this report code.
This is going to take some study and good testing. @pixkat -- maybe you can use your 100,000 submission database for some testing.
Comment #8
pixlkat commented@DanChadwick -- I agree; once I started looking at the form code, I was very confused why webform_download_sids() was being called all over the place. I was more interested in the short term in getting a functioning download, which is why I just removed the assignment. I will look at this further; I think cleaning this up will increase performance a good bit -- I was running into php timeouts with my maximum execution timeout set to 30 seconds. I'd rather see all this fixed than have to try to guess what a reasonable maximum batch size is based upon testing of my arbitrary form data.
Comment #9
danchadwick commentedI re-worked both the submission and the report_sid code to split the generation of the query from the execution of the query. I then used the optimization in the related "10x speed improvement"* issue when fetching the submission data. However, I'm not getting good performance, so I need to see if it is related to my development machine, the code changes, or something else.
What batch size were you able to use successfully and on what server? The 10,000 "time" limit seems overly optimistic. It may have been picked before all the hooks were added to each submission. I'm thinking something more like 1000 might be more reasonable, and even that might be high.
* The idea is to use an IN condition with a subquery, rather than a huge array of SIDs and an IN clause. When I generalized it to work with any query, rather than all the submissions, it doesn't work because IN doesn't support LIMIT clauses in the SID. So I went to a join, which may not be fast.
If you're willing to test some patches with your big database of submissions and your server, that might be helpful. I made a 20K submission webform for testing.
Comment #10
pixlkat commentedYes, I'd be willing to test some patches. I was able to get to about 8000 before I started seeing regular timeouts; 5000 seemed to be a reasonable number. Currently I"m testing on my local mac dev -- 2014 macbook pro retina with 16G of ram. My form contains 8 components.
Seems kind of hackish, but if you can't use LIMIT in a subquery, since you already keep track of the last sid downloaded, use a WHERE clause where sid >= $last_sid and < $last_sid+$batch_size? You may not always receive $batch_size submissions, but you would at least not receive more than that.
Comment #11
danchadwick commentedOK. I figured out my performance issues, which were actually functional issues too.
On my development server, I went from exporting a batch of 10,000 records with the current dev in 41 seconds to 21 seconds with this patch. The time to load the data for 10,000 submissions went from about 16.5 seconds to about 1 second. This is the power of using the join rather than an IN clause with an array of 10,000 sid's.
This patch has two main parts. The following should help with a code review.
webform.submissions.inc
webform.report.inc
Patch forthcoming after some additional testing. Yikes.
Comment #12
danchadwick commentedHere's the patch. It also cleans up the writing of the last download sid. Needs testing and review.
Comment #13
fenstratSome substantial changes here, but the performance gains look good.
At this stage I've not tested the patch so this is just a code review. Overall the theory of using the JOIN rather than IN makes a lot of sense. Splitting webform_get_submissions() into _query and _load seems ok, especially for BC.
Following are mostly nits:
While this isn't exactly a public facing function changing its signature in the stable 7-x-4.x branch probably isn't a good idea. $submissions as the last param perhaps?
> 80 chars. Quite a few instances of this.
Whitespace.
Should be on one line, no trailing line.
Again, one line.
int > in
7.x-5.x hey, good on you Dan!
Comment #14
torotil commentedAs for the batch size: I usually go for 100 for entities (which obviously are still a lot slower in D7). As a general rule of thumb each batch should take a few seconds (2-5). Then the time spent with querying the database for the data of the next batch is negligible compared with the time spent doing the actual calculations - but it still allows for a fine control wrt to the max execution time. The filter query seems to be simple enough that it's feasible to run it again for every batch. We have to look out for that once we allow for more complex filters though (>8 joins or GROUP BYs).
Also it depends a bit on the size of one single submission. If we store large text-areas with KBytes of text 1000 submissions already use up MBytes of RAM. Essentially we have to limit it to (memory_limit - rest_of_drupal)/(average memory size of a submission).
I've also found two other suggestions for the code:
You could also do a
$submission_query_fields = array();instead.That's duplicated and perhaps should unset $submission_query_tables the second time.
Good work!
Comment #15
danchadwick commentedUpdated patch based on comments.
#13.1 - I suppose, although it would seem very unlikely that anyone would call it. I can't just change the parameter order because the functionality is actually split into two function; $submissions isn't optional. I moved the obsolete submission retrieval code back out of webform_results_export and back into webform_results_download_rows. I then moved the body of webform_results_download_rows into a new function webform_results_download_rows_process. I put the submissions as the last parameter to harmonize the parameter order of the two, and made the $start_serial not optional (which is fine since this is a new function. A bit messier, but does support someone calling webform_ressults_download_rows(). This begs the issue of which functions in a module are public and which ones aren't. Just because it doesn't start with an underscore doesn't mean someone should be calling it.
#13.2 - I was astonished to re-read the coding standards and find that all comments are supposed to be <= 80 columns. I could have sworn it was just docblocks. This is, of course, a very stupid standard but I will abide by it when there is no reason not to. I quickly re-wrapped these two source files, rather than try to find the new comments and just wrap those.
#13.5 - Ha. I also didn't know that the first line summary had to be both 1 line and <= 80 columns. Ya know, because it's more important to be brief than informative. Someone might be browsing code on a VT-100 terminal.
#14.1 - D'oh. Of course. I copy/pasted code from SelectQuery::countQuery and didn't notice what I was doing.
#14.2 - Good catch! PHP references that aren't formal arguments are timebombs waiting to create bugs.
Comment #16
pixlkat commentedI applied this patch and tested against a webform with 100K submissions. I was able to successfully download the entire 100K (using the default "All" range option) in less than 5 minutes (I have not done any serious timing, just noting the time when I started) and part of that was opening the file once I had it. The various range options all performed as expected, giving me the submissions I requested. I added more submissions to the form and used the "download since..." option which also performed as expected.
My only nit from testing is that when applying the patch locally, git complained about whitespace errors.
Thanks so much for this!
Comment #17
danchadwick commented@pixlkat - "this patch" -- do you #12 or #15 (which I mistakenly called -14.patch)?
Did you use the default batch size of 10000, or something smaller? If so, what?
Comment #18
danchadwick commented@torotil -- The batch size guestimate is based upon both memory and time. The time limit is 10000. I want to hear what pixlkat has to say, but on my relatively slow windows machine, a 10K batch took about 21 seconds. I'm thinking maybe of changing the 10K to 5K. The drush limit can stay 10K since time isn't a factor with drush.
Comment #19
pixlkat commented@DanChadwick -- I tested with both #12 and #15, but more thoroughly with #12. I was using the default 10K batch size. I have not yet had a chance to test this on any other environment than my local development as dev environments in our hosting cloud environment have been hard to come by lately. Let me set the batch size to 5000 here and see what kind of time I get.
Comment #20
pixlkat commentedI did a couple of downloads with the batch size set at first 10,000 and then at 5,000. I attempted to time them with a stopwatch which I started when i clicked the download button, and stopped when the file download dialog appeared. This is on my macbook pro (retina, 16G ram) with a submission count of 115K.
10K batch size: 3:18
5K batch size: 3:25
Comments which arose out of my functional testing:
If you are indeed intending to completely remove the tables from this query, I think it is unnecessary to iterate through them to unset a specific element first.
$condition is not always an array here, and when it isn't the if statement generates "Illegal string offset 'operator'" PHP warnings. Add is_array($condition) check here.
Comment #21
pixlkat commentedComment #22
danchadwick commentedRe #20-1 -- That's not what that unset does. $submission_query_tables is a reference. You should always unset a reference after you're done with it because if you mistakenly reuse the variable (easy to do if it's called, say, $node) then you end up setting what the variable is referred to, rather than it.
Re #20-2 -- Wow, it's worse than that. I didn't read SelectQuery::conditions() closely enough. It does return a '#conjunction' => 'AND' (or 'OR') element, which needs to be skipped. But worse, my "test" for $condition['field'] was actually an assignment. I think it just happened to work because the nid was always the first condition. Yikes, that could have been a nasty bug to find.
Thanks for the review! This patch fixes #20-2.
Based on your numbers and my testing which showed that I used 2/3rd of the 30 second timeout, I think lowering the default batch size to 5000 for the time limit is prudent. A 3% time penalty seems worthwhile for dramatically increasing the margin. My testing of a 5000 batch was about 10 seconds, giving an ample margin.
Comment #23
danchadwick commentedPS. I'm using PHP 5.3.13, which casts the 'operator' to 0 and returns the first character of '#conjunction', a '#' which is not equal to '='. So that's why it worked for me and I got no warnings.
You must be using a different version. And I'm using error_reporting of E_ALL | E_STRICT. Sheesh.
Comment #24
pixlkat commentedI'm using PHP 5.4.38 on my local dev environment so it is a lot whinier. On this project, all our dev environments have error_reporting set to E_ALL in settings.php so I see everything.
On #20-2, I completely missed the assignment operators in the condition testing; good thing I have all those php errors showing up. I did not see it until I happened to visit the submissions page for one of the webforms instead of going directly to the download form.
I agree, I think a 5,000 batch limit is a good idea -- even though it took a few seconds longer, it seemed to go faster with the additional UI progress updates, so I think it is a win all around.
This looks really good to me. Thanks again for doing this.
Comment #25
fenstratJust quickly Re: coding standards, all comments <= 80 chars, however function descriptions can be > 80 but must be on one line. Also, there's still whitespace in the patch, not sure what your editor is Dan but try setting it to get rid of it for you. Good work here people!
Comment #26
danchadwick commentedCoding standards:
There are exceptions -- hook_update_N comments are used in the UI, for example.
What whitespace are you seeing? I use Komodo IDE and it is set to remove trailing whitespace and fix EOL markers, but only for changed lines. I think this is the best/correct setting.
Comment #27
quicksketchGreat job on this Dan. Passing along a select query looks like a much better solution than the $sids array all over the place. FYI, we were using SIDs previously because we were coming from a place (D6) where there wasn't such a thing as a query object. Additionally, older versions of MySQL we used to support did not support subqueries. We still don't use them frequently in Drupal, it would be prudent to test this set of changes on PostGres to ensure we're not breaking support there.
Double semicolon at the end of the line.
This conversion from one query type to another is really difficult to follow and is deeply tied to DBTNG's internals. Is there a way we could avoid this conversion and pass in an adequate query in the first place? Or if this optimization is only for backwards-compatibility, it would be fine to leave the
IN (:sid)situation unoptimized. Given the general utility of this function, I expect it will start getting used to load submissions from manually-assembled queries that may not match the input this conversion expects. The less modification we apply to the incoming query the better.Could we just pass in the NID as an argument to
webform_get_submissions_load()instead of doing this kind of messy extraction?Overall I think this looks great.
Comment #28
danchadwick commentedRe #28.
You can never have enough semi-colons. ;)
I'll ask Liam to test with Postgresql. I think he uses it. From googling, it looks like joins on subqueries are okay.
I'll add some comments to the subquery maker. The only way around this that I can imagine would be to create a subclass of SelectQuery that uses an interface that adds fields and the subquery has been created. We'd make one for webform_get_submissions and one for download. Doesn't seem much better. And I'd venture that if it doesn't work with our current code, it wouldn't work with the OOP version either. This isn't a general purpose function. We are the only ones calling it, and the query needs to be of the right sort. My goal wasn't to extend webform, but rather to solve the problem of querying on big arrays of sids with an IN clause.
I think it is better and safer to infer the nid from the query. This way, the caller never gets it wrong and creates a silent performance problem.
Comment #29
fenstratRe: Coding Standards, huh, I stand corrected. It appears function summaries used to be allowed to go over 80 chars, not any more.
Re: White space, probably the easiest way to see them is to review the patch in Dreditor. Git also complains about whitespace errors when applying the patch (as noted by @pixlkat), though that probably depends on your whitespace settings in .gitignore.
Comment #30
danchadwick commented@quicksketch -- I think I misunderstood the gist of your comment re the join. I did some testing and, at least with the queries we are generating, there isn't any harm in leaving the fields in the query. This was actually left over from when I was using an IN clause (as originally suggested in the related performance issue). Accordingly, I have deleted the field-deleting code. In my testing, it adds about 10% execution time to the query, but that time is swamped by the processing of the results of the query (e.g. downloading the data).
I'm pretty confident that any SQL can implement a join on a subquery. That's pretty basic stuff. Not too worried about postgres and ms sql, but if we have to, we can always add an engine test to using the join.
@fenstrat -- Thanks. I added a couple of whitespace-related options to my .gitconfig. Still, it's odd that KomodoIDE didn't clean up the changed lines. I cleaned the two related files, and re-wrapped comments.
I found a bug due to confusion over fields qualified with table aliases and not. I changed webform_get_submissions_query() to start by qualifying all the filter column names.
@LiamMorland -- please review this patch for postgres compatibility.
Comment #32
danchadwick commentedCommitted #30 to 7.x-4.x and 8.x.
Note that currently d.o is broken and doesn't generate commit comments for the 7.x-4.x branch. Rest assured, it was committed and pushed.
If further testing reveals database engine regression, please re-open with details and the name of the offending database engine.
Comment #33
fenstratGreat work here Dan, thanks.
Comment #34
liam morlandWhen I tried exporting form results with a PostgreSQL backend, I got the following error:
The attached patch fixed the issue.
Comment #37
danchadwick commentedTested on MySQL. Committed to 7.x-4.x and 8.x. Thanks, Liam Morland.