Large Analytics SQL Queries are a Code Smell

A single large query in SQL can be hard to understand, test, debug, or change in the same way that an over-large function in code can be. A large query is also much harder to write! Feedback loops while writing large queries are slow and you'll often find yourself needing to guess at where the problem in your query is.

When I started writing analytics queries, I wrote some pretty rough ones that are now hard to debug and maintain! (Apologies to everyone who has had to deal with any of my old SQL queries.) Over time, I think I've gotten better at them, and I wanted to write down some of the things I'm doing differently now that I hope will be useful to other people in similar situations.

Finally, I'm a product engineer who rarely writes these sorts of queries! This advice is much less applicable to someone who does this daily & is using better & more focused tools to do analyses.

Temporary tables, views, and with

Large queries are hard, but it's pretty simple to break a large query into smaller pieces. For analytics queries, I normally create small temporary tables (often with the temporary keyword) that normalize data, filter out deleted rows and rows I'm not interested in, and organize my data into a format that makes querying easy. Views or with clauses can accomplish similar things, but I like using temporary tables for this because they cache results and make subsequent queries faster.

I also try to put constants into a temporary table or with clause. When working on a query, it can be easy to forget to update a constant in one spot and then get completely meaningless results. (Shoutout to Ben Haley for showing me this trick!)

All of this might sound a little abstract: let's take a somewhat contrived query and try to refactor it. We want to bucket and count US-based teachers who were active in 2021 by how many classes they created during that time period. Here's what that might look like as a single query:

select 
  case when class_count < 5 then class_count::varchar else 'many' end as bucket,
  count(*)
  from (
    select count(distinct class.classId) as class_count
    from teacher
    join user_teacher ON teacher.teacherId = user_teacher.teacherid
    -- left join class_teacher to make sure we're counting teachers who haven't created classes
    left join class_teacher on class_teacher.teacherId = user_teacher.teacherId and class_teacher.creator
    left join user USING(userId)
    join class using(classId)
    join (
      select distinct teacherId
      from teacher_active
      where active_date between '2021-01-01' and '2022-01-01'
    ) as ats on teacher.teacherId = ats.teacherId
    and class.createdat between '2021-01-01' and '2022-01-01'
    and not class.autocreated_demo
    and lower(user.country) in ('usa', 'us')
    group by teacherId
  )
group by 1
 

This query isn't particularly complex, but it's still enough logic that I'd be a little worried about changing it or verifying that it's correct. I'd be tempted to try to pull out constants and then separate out the filtering logic from the calculation logic.

drop table if exists _constant;
create temporary table _constant as (
 select '2021-01-01' as start, '2022-01-01' as end
);
 
drop table if exists _teacher;
create temporary table _teacher as (
 -- us_user is probably overkill: this might be better in the `where` clause!
 with us_user as (
   select userId
   from user
   where lower(country) in ('usa', 'us')
 )
 select distinct teacherId
 from teacher_active
 join user_teacher USING(teacherId)
 join us_user using(userid)
 where active_date between (select start from _constant)
   and (select end from _constant)
);
drop table if exists _class;
create temporary table _class (
 select classId
 from class
 where class.created between (select start from _constant)
   and (select end from _constant)
   and not class.autocreated_demo
);
 
drop table if exists _classes_created_by_teacher;
create temporary table _classes_created_by_teacher (
 with class_creator as (
   select class_teacher.*
   from class_teacher
   join _class USING(classId)
   where class_teacher.creator
 )
 select teacherId, count(distinct classId) as classes_created
 from _teacher
 left join class_creator using(teacherId)
 group by teacherId
);
 
select
 case when class_count < 5 then class_count::varchar else 'many' end as bucket,
 count(*)
from _classes_created_by_teacher
group by bucket;
 

It's arguable whether this is actually better! The initial query is short enough that it's not that much logic to understand: it might be the right size for the team that you're working with. There are also certainly better ways of factoring this same query that could make the logic even more clear. Overall though, I'd much rather work with the updated query:

  • if something is broken in the query, I can easily and quickly examine the tables that I've created to see if my results match my expectations
  • if I have a testing library of some sort, I can set up simple assertions about what the tables I'm using look like
  • the overall query will run faster because results are cached and because query-planners don't always generate optimum plans for large queries. While working on this query, I'll have faster feedback loops
  • I'll be able to tell which parts of this query are slow and optimize if necessary
  • it's easier to focus on adapting & improving a single part
  • The sub-queries that I've turned into tables are things I could take advantage of later if I ever tackle other similar problems by turning them into nice non-temporary cache tables

I think many data-focused engineers use jupyter notebooks and pandas to break down large queries. I think how you're breaking down a large query into smaller pieces is much less important than doing that breakdown!

Make feedback loops FAST!

One of the most frustrating parts of working on a large query is that feedback loops can be slow. Making a change and waiting tens of minutes can completely kill any programming flow or focus that you have.

  • Break up large queries into smaller ones that are quick to run!
  • Use select without a from to quickly test out behavior. You can run queries like select extract('year' from GETDATE()); or select 1 != null, 1 is not null to quickly check your understanding. This can be especially useful for testing out regular expressions and formatting date fields for charts. There's no need for a from or to run a full query1
  • If a full table is slow to query, it might make sense to create a temporary table with representative data that you can use to build up your queries
  • Good indexes or sort-keys can drastically improve query speed! The most common mistake I've made is setting up a compound sort key with a too-precise timestamp followed by other keys I'm interested in. If you use a second or millisecond precision key at the start of a compound sortkey, that key's precision will override any possible benefit from the other key. So, rather than creating a sortkey like (createdAtMs, event), it's probably better to instead sort and query on an index like (day, event). (This might mean that queries need to include both day and createdAtMs.)
  • validate your work as you go! The quicker you can realize that your assumptions about a table or column are incorrect the better. Running a query that checks whether a column is unique, what values a column can hold, or just what data looks like can save a ton of time!

In general, putting effort into how quickly you get feedback while working makes it much easier to find flow and be effective. A little bit of effort put into setting up nice tables, improving data layout, and optimizing sortkeys can pay large dividends.

    In Part 1 we talked about why we switched from Google Analytics to Matomo. In Part 2, we discussed how we designed the architecture. Finally, here in Part 3 we will look at the Matomo specific changes necessary to support our architecture.

    First, we modified the Dockerfile so that we could run commands as part of the container startup. This allows classdojo_entrypoint.sh to run, but the process that the container ultimately creates is the long running apache2-foreground:

    # The matomo version here must exactly match the version in the matomo_plugin_download.sh script
    FROM matomo:4.2.1
    ADD classdojo_entrypoint.sh /classdojo_entrypoint.sh
    ADD ./tmp/SecurityInfo /var/www/html/plugins/SecurityInfo
    ADD ./tmp/QueuedTracking /var/www/html/plugins/QueuedTracking
    ADD ./tmp/dbip-city-lite-2021-03.mmdb /var/www/html/misc/DBIP-City.mmdb
    RUN chmod +x /classdojo_entrypoint.sh
    ENTRYPOINT ["/classdojo_entrypoint.sh"]
    CMD ["apache2-foreground"]
    

    Next, we wrote a script to download plugins and geolocation data, to bake into the Docker image:

    #!/bin/sh
    set -e
    
    MATOMO_VERSION="4.0.2"
    
    rm -rf ./tmp
    mkdir ./tmp/
    cd ./tmp/
    
    # This script downloads and unarchives plugins.  These plugins must be activated in the running docker container
    # to function, which happens in matomo_plugin_activate.sh
    curl -f https://plugins.matomo.org/api/2.0/plugins/QueuedTracking/download/${MATOMO_VERSION} --output QueuedTracking.zip
    unzip QueuedTracking.zip -d .
    rm QueuedTracking.zip
    curl -f https://plugins.matomo.org/api/2.0/plugins/SecurityInfo/download/${MATOMO_VERSION} --output SecurityInfo.zip
    unzip SecurityInfo.zip -d .
    rm SecurityInfo.zip
    
    curl -f https://download.db-ip.com/free/dbip-city-lite-2021-03.mmdb.gz --output dbip-city-lite-2021-03.mmdb.gz
    gunzip dbip-city-lite-2021-03.mmdb.gz
    
    cd ..
    

    Then we write the entrypoint file itself. Since we overwrote the original entrypoint, our entrypoint needs to unpack the Matomo image and fix some permissions first, but then we activate plugins that we want to include:

    #!/bin/sh
    set -e
    
    if [ ! -e matomo.php ]; then
      tar cf - --one-file-system -C /usr/src/matomo . | tar xf -
      chown -R www-data:www-data .
    fi
    
    mkdir -p /var/www/html/tmp/cache/tracker/
    mkdir -p /var/www/html/tmp/assets
    mkdir -p /var/www/html/tmp/templates_c
    chown -R www-data:www-data /var/www/html
    find /var/www/html/tmp/assets -type f -exec chmod 644 {} \;
    find /var/www/html/tmp/assets -type d -exec chmod 755 {} \;
    find /var/www/html/tmp/cache -type f -exec chmod 644 {} \;
    find /var/www/html/tmp/cache -type d -exec chmod 755 {} \;
    find /var/www/html/tmp/templates_c -type f -exec chmod 644 {} \;
    find /var/www/html/tmp/templates_c -type d -exec chmod 755 {} \;
    
    # activate matomo plugins that were downloaded and added to the image
    /var/www/html/console plugin:activate SecurityInfo
    /var/www/html/console plugin:activate QueuedTracking
    
    exec "$@"
    
    We tie it together with a Makefile to build and publish these Docker images:
    
    build-img:
    	sh ./matomo_plugin_download.sh
    	docker build . -t classdojo/matomo
    	rm -rf ./tmp
    
    push-img:
    	docker tag classdojo/matomo:latest xxx.dkr.ecr.us-east-1.amazonaws.com/classdojo/matomo:latest
    	docker tag classdojo/matomo:latest xxx.dkr.ecr.us-east-1.amazonaws.com/classdojo/matomo:${BUILD_STRING}
    	aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin xxx.dkr.ecr.us-east-1.amazonaws.com
    	docker push xxx.dkr.ecr.us-east-1.amazonaws.com/classdojo/matomo:latest
    	docker push xxx.dkr.ecr.us-east-1.amazonaws.com/classdojo/matomo:${BUILD_STRING}
    

    Inside our Nomad job specifications, we inject a config.ini.php file. This contains the customized config.ini.php for Matomo. It is a copy of the original Matomo config.ini.php file, but with some important changes:

    [General]
    proxy_client_headers[] = "HTTP_X_FORWARDED_FOR"
    force_ssl = 1
    enable_auto_update = 0
    multi_server_environment=1
    browser_archiving_disabled_enforce = 1
    

    Proxy_client_headers and force_ssl are used as part of our SSL setup. Enable_auto_update prevents containers from updating separately, so that we can coordinate updates across all containers. Multi_server_environment prevents plugin installation from the UI and disables UI changes that write to the config.ini.php file. Browser_archiving_disabled_enforce ensures that the archiving job is the only job that can run archiving, and that archiving won’t happen on demand.

    For our non-frontend ingestion containers, we also set:

    ; Maintenance mode disables the admin interface, but still allows tracking
    maintenance_mode = 1
    

    Another major change is that the Docker command for the queue processor is changed to:

            command    = "/bin/sh"
            args       = ["-c", "while true; do /var/www/html/console queuedtracking:process; done"]
    

    This allows the job to run in a loop, continuously processing the items in the queue.

    Similarly, the archive job is changed to:

            command    = "/var/www/html/console"
            args       = ["core:archive"]
    

    Which runs the archiving job directly. The admin and ingestion containers all use the default docker command and arguments.

    That’s the end of our current journey from Google Analytics to Matomo. There’s more work we have to do around production monitoring and making upgrades easier, but we’re very happy with the performance of Matomo at our scale, and its ability to grow with ClassDojo.

      In Part 1, we discussed why we were moving from Google analytics to Matomo. Now, in Part 2, we’ll talk about how we architected Matomo to handle traffic at ClassDojo’s scale.

      Architecture

      We are using the Matomo official docker image to run the Matomo PHP application. The application has a number of functions and provides:

      • An event ingestion endpoint (matomo.php)
      • An administration and reporting interface
      • Periodic jobs
      • Command line tools

      While a single container can perform all of these functions, they each have different performance and security characteristics and we've decided to separate them to take advantage of different configurations for each of them.

      Event ingestion

      First, we deployed an auto scaling multi container deployment to our Nomad clients. The containers serve matomo.js (a static javascript file) and matomo.php (the main php executable for event ingestion), and we route to these URLs via HAProxy.

      These event ingestion containers are publicly available, and contain another PHP script called index.php. This is the admin and reporting interface. We do not want to publicly expose this. Matomo can disable this interface by setting maintenance_mode=1 in the configuration file, and we've turned it on for these containers. Additionally, we rewrite all /ma/*.php script requests to /ma/matomo.php, which will force everyone to the event ingestion code instead of the admin code.

      Admin and reporting

      Next, we create a separate Nomad job for Matomo administration. It is deployed with a single container and haproxy will route to this container only on our internal network. Unlike the above, this one has the admin interface exposed. Matomo configuration happens in a combination of a PHP configuration file (config.ini.php) and in a mysql database. Changes that can be stored in the database are safe to use because they are synchronized across all running containers. But changes that are written to the config file are not, since they will only happen on the admin interface. For this reason, we set multi_server_environment=1 in the config file, which prevents changing any setting that would write to the config.ini.php file. Instead, these changes need to be deployed via nomad spec changes. Additionally, we turn auto updates off with enable_auto_update=0, so that matomo instances aren't updating themselves and trying to separately migrate the mysql database.

      Periodic jobs

      Out of the box, Matomo does everything on the tail end of user initiated scripts. This means when a user is using the admin site, Matomo might decide that it needs to do some log archiving, or report building. Or if there are events to process in a queue, Matomo might run them at the end of ingesting an event. This isn't ideal for us as it could create undesired performance problems (an admin site that slows down unexpectedly or tracking backing up and a queue growing too large). So we have disabled these periodic jobs (archiving and queue processing) and run them separately as 2 more Nomad periodic jobs. One job is for processing a queue of incoming events, and the second is for archiving our event databases

      Queue Processing

      By default, Matomo writes event entries directly to the database, but at our scale, we want to write to a fast queue, and then batch process the queue into the database. This lets us handle database failovers and upgrades, but also provides slack for when there is a spike in traffic. It also lets us run long queries on the admin site without worrying about impacting the incoming events. Matomo provides a QueueProcessing plugin that moves event ingestion to write to a redis queue. This is fast and reliable and can be processed out of band so that event ingestion can continue while DB maintenance happens.

      At first, we ran the queue processing job every minute as a Nomad periodic job. At our scale, we were not able to process the full queue in each minute, and events were backing up in the queue throughout the day. This caused delays in data showing up in matomo, but also we were running out of memory in Redis. We changed from a periodic job to a long running job that runs multiple queue workers (8 right now) by setting the numQueueWorkers setting in the QueuedTracking plugin. It’s important to remember to set both the numQueueWorkers setting and to create the same number of simultaneous queue worker jobs.

      Archiving

      Matomo stores each event individually, but also contains aggregate reports (today, this week, this month, last month, this year, etc). To build those reports, Matomo runs an "archive" process. This job runs once a day as a Nomad periodic job.

      We are happy with how we designed the Matomo architecture, but it took some time to get container configuration working. We’ll talk about this in Part 3.

        Newer posts
        Older posts