• Database optimization

    Sometimes, especially on interviews, developers are asked about what would be their steps to fix a database that works slowly. That’s a really extensive subject to discuss. Here, I decided to summarize my experience. I hope it help to not only increase the performance but do that in acceptable time and with less misery.

    First, of all, fixing the database queries is the last thing you should do. Fixing code first is premature optimization that is a root of all evil as we know. It could take huge amount of time whereas the real problem is kept in a wrong configuration file.

    So in my prospective, the right steps to make your database more peppy are something like follows below. Keep in mind that it is not a technical tutorial with code snippets that you could paste and make the job done.

    Ensure you use SSD drives. It sounds silly but still you should check it. SSD gives you up to 10 times performance boost immediately. Ensure that SSD drive is a highlighted feature in you hoster’s plan.

    Check you database config before tweaking something else. Usually, the default configurations are set up poorly to make the DB work on cheap computers. Some distributions carry config templates which names include memory or CPU values. Read them, they are well-documented. Google for best practices when configuring your database. Read books.

    Consider a case when the entire database is kept in memory. That also involves DBA and Ops teams so probably you won’t be allowed to perform such a move by your own. But since memory is cheat nowadays, it is possible. For backups, replicate such a database using streaming replications on servers that are run on hard drives. PostgreSQL supports various replication scenarios as well. You may also dump memory images into files and restore them on demand.

    I used to work in a project where they ran 400Gb database into RAM and it worked quite fast.

    Check you application uses connection pooling when communicating to the DB. Suddenly, such wildly spreaded frameworks as Django do not use them. On each HTTP request, they open a fresh connection and close it once the response has been sent. Now imagine your application serves 20 requests per second. Network communication will be quite expensive in that case!

    Consider using either PGBouncer in front your Postgres installation or any library for your language that deals with reusable connections by its own. Say, for Clojure, HikariCP would be a good choice.

    Everything that might be read from replica should be read from it, not the prod DB instance. Don’t run reports against the production. All the analytics and statistics should not touch it. Modern frameworks allow you to declare multiple database backends and switch between them on demand. For example, in Django, calling .using('replica') method on a Queryset instance dispatches a query into another database, not the default one.

    For quite complicated queries, use materialized views that may be turned into physical tables on demand. Querying them would be much faster than processing a query full of joins and aggregates every time. Setup a cron job to rebuild such views every night for example when the load is low.

    Select only those fields that you really need to process a request. Avoid using select * from table since it takes extra time for the database to find out what fields to fetch exactly. With Django ORM, either specify needed fields with .only() method or bypass some of them calling .defer(). But keep in mind that touching a non-fetched field hits the database again.

    When writing unit tests, add a case that counts a number of database queries made during request. Sometimes, it leads to terrifying findings. Touching fields of foreign entities in a cycle without joining them initially may end up with up to 100 queries or more.

    Be carefull when passing a collection of IDs as a parameter. On the database level, it turns into something like

    STATEMENT: SELECT * FROM users WHERE id IN (?, ?, ?, <100 more of them>)
    PARAMETERS: $1=10035, $2=10036, $3=10037, $4=10038, $5=10039,...
    

    When not limiting the number of such IDs, one may end up with 1000 of them what is quite harmful for the database. Either process them by chunks or try to compose a single query with raw SQL.

    For fast data processing, don’t use ORM at all. Building a model instance from a low-lever tuple that the database driver returns is really costly. Even SQLAlchemy (Python’s the most powerful ORM) spends double time on building instances than reading data from psycopg2 (Python PostgreSQL driver) directly.

    Denormalize your tables. Sometimes, it really worth moving any columns into foreign tables and joining them on demand. A good example might be ranking system. You’ve got a users table full of fields. Now you need to add rank field and recalculate it every 6 hours. Adding yet another fields would be a bad choice since our users table is already under load.

    But having the users_ranks table that has a unique foreign key to a user and a rank value would be a good choice. Now that, we may rewrite it as we wish without touching actual user’s data. When needing to sort users by their rank, we join that table and order the result by rank as well.

    The same relates to a search document. When implementing full search on some entity, we need to build a special document known as a search vector. Storing that vector inside an entity is a bad choice because it is not the data but just technical information.

    Join tables only by foreign and primary keys but not any custom conditions like strings equality, regex matching and so on. When you need such a join, probably it is better to fetch both parts of it and compose the result in your app’s code.

    When a query really works badly, examine it with EXPLAIN operator. If you don’t understand its output completely, use online services that turn it into more human-friendly format. Ensure the query hits indexes. Don’t add multiple indexes since having them a lot may slow down writing procedure.

    Finally, never be afraid of raw SQL. No matter how powerful your ORM is, check what queries it produces exactly. On my dev machine, I always start Postgres not as a service but as a process passing -E flag to print all the queries:

    postgres -E -D /usr/local/var/postgres
    

    That gives me the whole vision of what’s going on with the database.

    Summarizing all together:

    1. use modern hardware;
    2. configure your database properly;
    3. know all the shadowed parts of your ORM;
    4. rise your SQL skills.
  • Clojure in Highload Cup

    1st September was the day when the Highload Cup competition has finished. I’m proud of I took participation in it. The Cup was a quite interesting event full of drive, enthusiasm and night coding. Although I haven’t taken any prize, I’ve got lots of fun and new knowledge that I’ve partially shared with you in my previous publications.

    In that post, I’m going to highlight some technical details unmentioned before.

    A minute of vanity: I’m a single developer who used Clojure/Datomic stack to implement a solution. And by the way a single member from my hometown Voronezh (my colleagues who live here argued on Cup passionately but still without a result).

    The task was easy only at first glance. For non-Russian speakers, let me retell it quickly. You’ve got three entities: a user, a location and a visit. They represent, respectively, a physical person, a place in the world and a fact that somebody visited a place and put mark for it. Say, the last year John Doe visited Siberia and was so excited that he put 5 stars.

    Your task is to ship a Docker container that carries a REST server. The server should implement basic CRUD operations on users, locations and visits. All the data pass in and out using JSON.

    In addition to CRUD, there are two aggregate APIs. The first one is to return all the places visited by specific user. The second one is to return an average mark assigned to specific location by all the users who have ever visited it. Both APIs accept optional query string arguments to filter the results by foreign entities. Say, fromAge parameter stands for we need to consider only those people who are alder than that number, distanceTo limits those locations with distances less than the passed value and so on.

    Once you’ve built a Docker container, you submit it to the central server where it is shot with a special software. It considers lots of such facts as proper response codes, incorrect data filtering, response time and so on. Than it calculates your rank. The less is the rank, the better your position is.

    Sounds simple, but I spent a week trying to implement fast Clojure solution. TL/DR: finally, the C++ guys have come and taken the top of the rank table. Some of them wrote their own HTTP server. But still, it was quite fun to compete with them.

    As the Cup has finished, you are welcome to review my code (it was private before due to Cup rules). The final version uses Clojure 1.9, Datomic free edition and clojure.spec to validate incoming data. There were some experiments with SQLite database kept in memory but at the end I finished with Datomic (more on that below).

    So here are some technical details that I wanted to discuss.

    Reading ZIP on the fly

    According to the Cup’s rules, when your application starts, it finds the input data in /tmp/data directory. There is a single zip archive with JSON files inside. The Docker container is mount with read-only file system, so you cannot unzip it using standard Unix tools. Instead, you should read the data directly using streams.

    Thanks to Java, it ships java.util.zip package with all we need inside. Surprisingly, I ended up with quite short code to read the file:

    (defn read-zip [path]
     (let [zip (java.util.zip.ZipFile. path)
        entries (-> zip .entries enumeration-seq)]
      (for [e entries]
       (.getInputStream zip e))))
    

    It accepts path to a zip file and returns a lazy sequence of input streams. Each stream might be read into a data structure with a function:

    (defn read-stream [stream]
     (json/parse-stream (io/reader stream) true))
    

    , where json is an alias to the Cheshire library included as [cheshire.core :as json] at the top of the namespace.

    The data backend

    Since the beginning it was obvious to keep the data in memory but not on the disk. I was thinking on whether I should use in-memory SQLite backend or use Datomic within in-memory storage. After all, I’ve tried both options and ended up with Datomic finally.

    With SQLite, I’ve got only one trouble when connecting to the database. I described the problem in details in my previous post “In-Memory SQLite Database In Clojure”. For the rest, it worked fine. I used HugSQL to compose queries like this:

    -- :name get-location-avg :? :1
    select
      avg(v.mark) as avg
    from visits v
    /*~ (when (or (:fromAge params) (:toAge params) (:gender params)) */
    join users u on v.user = u.id
    /*~ ) ~*/
    where
      v.location = :location_id
      /*~ (when (:fromDate params) */
      and v.visited_at > :fromDate
      /*~ ) ~*/
      /*~ (when (:toDate params) */
      and v.visited_at < :toDate
      /*~ ) ~*/
      /*~ (when (:fromAge params) */
      and u.birth_date < :fromAge
      /*~ ) ~*/
      /*~ (when (:toAge params) */
      and u.birth_date > :toAge
      /*~ ) ~*/
      /*~ (when (:gender params) */
      and u.gender = :gender
      /*~ ) ~*/
    

    Then I switched to Datomic backend. I was wondering whether it would be slower than good old SQLite. The results were in favor of Datomic: it was about 1.5 times faster when returning responses.

    For in-memory backend, you do not need a registered version or a license key. Just add [com.datomic/datomic-free "0.9.5561.54"] into dependencies list and I’ve done. Then pass something like "datomic:mem://highloadcup" when connecting to the database.

    It was a good decision to create common functions for CRUD operations (create-user, update-user, etc). In fact, I had only three general functions to create, update and read for something, and the entity-specific functions became just partials on them.

    Having that, I could quickly switch from SQLite-powered backed to Datomic.

    The only think I’ve got stuck on was applying optional filters to a query. That became a reason to write “Conditional Queries in Datomic” article.

    You may examine Datomic database backend in master branch whereas SQLite version lives in a self-titled branch.

    JSON validation

    A system that tests you server tends to send incorrect data. If you accept it without returning 400 Bad Request status you will get penalty score. So the validation is a major part of our application.

    Before, I used Schema module for that purpose. I know it well including some of its shadowed parts. But having Clojure 1.9 on board was a great chance to try clojure.spec that is still in alpha but works great.

    After some REPL experiments, I ended up with my own highloadcup.spec namespace that carried wrappers around the original spec. One of them is validate function that does the following:

    1. validates the data against a spec;
    2. coerces string numbers into integers when needed;
    3. returns nil when the data is invalid.

    Its code is

    (ns highloadcup.spec
     (:require [clojure.spec.alpha :as s]))
    
    (def invalid :clojure.spec.alpha/invalid)
    
    (defn validate [spec value]
     (let [result (s/conform spec value)]
      (when-not (= result invalid)
       result)))
    

    Pay attention it’s a good practice to declare invalid constant at the top. Once the library becomes stable, its namespace will get rid of “alpha”.

    Another point, spec was designed to be used with full-qualified keys. But in my case, all the keys were without namespaces. That’s normal for non-Clojure applications. Declare your specs as usual, but once you compose a map of them, pass :opt-un parameter (stands for “unqualified”):

    (def enum-gender #{"m" "f"})
    
    (s/def :user/id int?)
    (s/def :user/email string?)
    (s/def :user/first_name string?)
    (s/def :user/last_name string?)
    (s/def :user/gender enum-gender)
    (s/def :user/birth_date int?)
    
    (s/def :user/create
     (s/keys :req-un [:user/id
                      :user/email
                      :user/first_name
                      :user/last_name
                      :user/gender
                      :user/birth_date]))
    

    This is a spec for creating a user where every field is required. For updating a user, there is a similar spec with all the fields optional:

    (s/def :user/update
     (s/keys :opt-un [:user/email
                      :user/first_name
                      :user/last_name
                      :user/gender
                      :user/birth_date]))
    

    When applying query string filters, they are all plain strings even when represent numbers. Turning them to the proper type is also knowing as coercion. To coerce a string value during validation, use conformer:

    (defn x-integer? [x]
     (if (integer? x)
      x
      (if (string? x)
       (try
        (Integer/parseInt x)
        (catch Exception e
         invalid))
       invalid)))
    
    (def ->int (s/conformer x-integer?))
    
    (s/def :opt.visits/fromDate ->int)
    (s/def :opt.visits/toDate ->int)
    (s/def :opt.visits/country string?)
    (s/def :opt.visits/toDistance ->int)
    
    (s/def :opt.visits/params
     (s/keys :opt-un [:opt.visits/fromDate
              :opt.visits/toDate
              :opt.visits/country
              :opt.visits/toDistance]))
    

    Now, then you validate parameters taken from the Ring request against :opt.visits/params spec, all the numbers represented with strings will be turned into integers as well.

    Docker

    Let’s talk a bit about building a Docker container. I don’t see any reason to compile uberjar inside Docker. It’s Java so that it is “compiled once, works everywhere” (usually I’m sceptical on that point, but not now). All you need is to copy an uberjar file into container and setup CMD properly.

    Do not use the official Java template. Under the hood, it ships OpenJDK that is 1.5 times slower than OracleJDK, unfortunately. So I had to inherit my image from Vlad Frolov’s one. I know that’s illegal to distribute Java runtime as a part of your application. But the difference in score was more important these days.

    JVM flags also could help to tweak performance. The web pages I’ve found googling for “java docker” said that Java has troubles detecting heap size when running in Docker. So at least "-Xmx" and "-Xms" should be specified. Next two, "-da" and "-dsa" reduce all the assert statements. See the rest of flags in my Dockerfile.

    All the project duties (lein, docker, files) should be automated with good old Make utility. Ideally, the default target should build the entire project from scratch.

    Acknowledgments

    I want to thank Mail.ru team who were responsible for handling that Cup. They’ve done really huge amount of work. I thought they didn’t sleep at all. During the three weeks term, they’ve been answering all the questions in chat, fixing the infrastructure, solving bugs, writing wiki and adapting the official website.

    Thank you guys, your are real professionals!

    PS to my Russian readers: you are welcome to share that post with your foreign colleagues. Next time, let them join Highload Cup too!

  • Мобильное приложение

    Ох, что я в интернете прочел.

    Значит, проблема: люди тупят в телефон на ходу. Спотыкаются, врезаются в столбы, падают в фонтаны.

    Знаете, что предлагает автор? Готовы? Написать мобильное приложение, которое будет читать камеру, распознавать препятствия и сигналить пользователю.

    Похоже, автор (дизайнер) не читал параграф Лебедева об идее на минус миллион. Это когда дизайнер думает, что идея офигенна только потому, что он ее придумал, а проблемы бизнеса не его дело.

    Давайте представим такое приложение. Батарея будет садиться в один момент. Чтение камеры это дорогая операция, а распознавание изображений еще дороже. При этом нужно, чтобы не подвисало основное приложение, с которым пользователь сейчас работает.

    Это технические трудности, предположим, мы их решили.

    При ходьбе камера смотрит почти отвесно вниз, поля обзора хватит на два метра вперед. Это расстояние человек пройдет за две секунды. Даже если приложение справится за 0.1 секунды, бедняга не успеет остановиться.

    Пользователь же не просто смотрит в экран, он мысленно погружается в контекст. Вот идет Вася, читает Вконтактик. Маша пишет: приходи ко мне ночевать. Все, у Васи мысли о сексе, кровь отлила от мозга в пах. А через два метра открытый люк.

    Поможет ли Васе приложение? Сомневаюсь.

    Как различать опасности? Это может быть и бордюр, и сливная решетка, и кусок брусчатки, приподнятый на сантиметр и потому невидимый приложению.

    В толпе люди разделены метром. Человек впереди это препятствие?

    Даже если представить, что приложение использует новейшие технологии (нейросети, глубокое обучение), это делает его хуже. Оно даст ложное чувство защиты. Человек будет выходить на середину дороги и попадать под автобус.

    В странах с развитой судебной системой производителя завалят исками. Люди будут так же падать, разбивать носы, но вдобавок отсуживать суммы за ущерб здоровью.

    В защиту этой ахинеи можно сказать, что для слепых делают что-то отдаленно похожее. Такие перчатки-локаторы чтобы надевать на запястье. Внутри Ардуино, эхо-локатор и пуговица-вибратор. Ультразвук отражается от препятствия. Если дистанция меньше метра, вибратор дрожит.

    Даже с этим девайсом масса проблем, при том что слепой каждую секунду слушает сигнал и все мысли его о том, как бы не упасть. И дальность локатора 15 метров против двух камеры.

    Вывод: если мобильный телефон приносит проблемы, нужно убрать телефон. Делать мобильное приложение – что-то за гранью мыслимого. Хуже, чем лечить рак легких щадащими сигаретами.

  • Центрирование курсора

    editors

    В редакторах или IDE крайне редко встречается следующая функция.

    Предположим, я дописываю код в конец файла. Если кода больше, чем один экран, текст можно набирать только внизу. Промотать редактор на середину нельзя – файл закончился, скролл вниз не работает.

    Это страшно раздражает. Я привык, что глаза смотрят в центр экрана. Гораздо чаще мы редактируем старый код нежели пишем новый, поэтому курсор в центре. Долго смотреть вниз неудобно, устают глаза и шея.

    Слева на картинке Саблайм. Файл закончился, набирать текст приходится внизу, смотаться нельзя. Емакс проматывается в середину сам. Сочетание Ctrl+L центрирует буфер по требованию, повторное нажатие перематывает вверх, оставляя одну строчку. Обратите внимание, что буфер кончился, слева нет нумерации строк, но для перемотки это не проблема.

    В VS Code смотаться можно, но при редактировании внизу он проматывает на одну строчку. Приходится мотать вручную, выполняя работу машины. Написал-промотал. Емакс же сразу прыгает в центр экрана.

    IDEA по умолчанию оставляет задел в пять пустых строчек в конце файла, но ниже нельзя. Нормальная промотка включается где-то в недрах настроек. Опять же, нет авто-центрирования.

    Раньше я боролся с напастью так: зажимал Энтер и вставлял 50 переносов. Так я мог проматывать файл ниже его логического конца, а с точки зрения редактора от все еще продолжался. Время от времени эта дичь попадала в коммиты. Был и другой вариант – сплющить окно редактора, чтобы оно доходило только до середины экрана.

    Почему об этом думали в 1970-бородатом году, а сейчас никого не волнует?

  • Вконтакт

    Не могу передать, как меня бесит ВК. В нем все плохо. Дурацкое название с предлогом. Убогий дизайн, когда нажимаешь на выпадашку, а там одна опция. Но главная беда как раз в том, ради чего это это создавалось – в общении.

    Предположим, хочу повесить объявление в группе жильцов ЖК. В шапке группы жирная кнопка “написать сообщение”. Открывается диалог:

    vk

    Минуточку, мне нужно, чтобы отвечали все, а не только администраторы. Или остальные тоже могут? Как понять?

    Что за ссылка “перейти к диалогу с сообществом”? А сейчас что, не диалог? На всякий случай нажал, там пустой экран. Ясно, этим не пользуются. Зачем ссылка?

    В группе есть “обсуждения”: жильцы второго дома, любители котят и тд. Этакий форум из нулевых. Объявление в одном обсуждении не увидят в другом.

    Сбоку ссылка на чат. Что-то вроде Телеграма, только внутри ВК. Слева личные сообщения.

    Черт, как разместить текст, чтобы его прочли все жильцы?

    Все засунуто под выпадашки, даже когда места полно. Всюду “еще”, “подробней” и прочая импотенция.

    Что за дизайнеры верстали все это? Кто это проектировал?

    Я слышу: сперва добейся, потом предъявляй. Но совершенно верно сказал Лебедев: если известная субстанция приносит много денег, быть ею от этого она не перестает.

  • Дети

    Чтобы ребенку было интересно, нужно, чтобы родителю было интересно. Чтобы ребенку нравилось, нужно, чтобы родителю тоже нравилось.

    Удивительно, как много взрослых не понимают этой банальности. Они считают, что ребенку можно на весь день включить мультики и забыть о его существовании. Что можно подарить книгу, и типа читай сам. Что можно подарить китайское говно взамен качественной вещи.

    Дети все прекрасно понимают. В силу физиологии они не могут развернуто сформировать претензию или обиду. Но халтуру чуют за версту. Это особенно заметно в период с 2 до 3 лет, когда речь еще толком не поставлена, но уже есть устойчивые вкусы и понимание всего, что происходит вокруг.

    Чтобы ребенок с упоением читал, нужно читать ему каждый день несколько лет. Чтобы с интересом смотрел мультик и понимал сюжет, нужно смотреть вместе с ним.

    Качественная детская продукция мало чем отличается от аналогов для взрослых. Если это игрушка, она должна иметь приятные пропорции, быть устойчивой к неаккуратному обращению, не звучать слишком громко. Если техника, то быть правильно спроектированной, с понятным дизайном, качественной сборкой.

    Если это мультфильм, то повествование должно быть последовательным, проблемы персонажей понятны, хорошо прослеживается главная мысль: обманывать нехорошо или если накосячил, то исправь.

    Никогда не делайте скидку на то, что это ребенок. Он такой же человек, как и вы. Критерии качества у детей такие же высокие.

    Остается печальный случай, когда ребенок потребляет халтуру (вещи, информацию) из-за отсутствия вкуса у родителей. Увы, эта проблема неразрешима и будет передана на поколение вперед.

    Может, и ваш ребенок одупляет телевизор третий час, пока вы это читаете?

  • Ворчание ягнят

    Никита и Рахим запустили сайт, где можно посетовать на дебильный интерфейс. Встречайте, Ворчание ягнят. Написан на Кложе.

    Дизайн не моя стихия, и писать в блог про каждую кривую кнопку мне кажется лишним. Это скорее для Твиттера, но я с ним не дружен. Так что все наболевшее шлю в приват Никите, а он обещал запостить.

    Подписывайтесь по РСС, там будет изредка мелькать и от моего имени.

    UPD стрим в процессе создания:

  • Фаерфокс

    Мне кажется, в недалеком будущем Фаерфокс умрет. Не полностью и не буквально, конечно. Просто сократит долю рынка настолько, что станет неликвидным. Ему присвоят звание “жемчужины опен-сорса”, гики будут стенать, причитать и сидеть на неподдерживаемой версии трехлетней давности, как это случилось с Оперой после перехода на Веб-кит. На поддержке останутся FF-only легаси-системы.

    Кто-то сказал, что браузеры – продукт массового потребления. В целом, они все работают хорошо и покрывают нужды рядового пользователя. Уже нет проблем с отрисовкой элементов. Основные усилия уходят на то, чтобы еще теснее интегрировать браузер с операционной системой и сервисами компаний.

    В этом плане все в порядке у Хрома, Сафари и Эджа (который Эксплорер). Гугл вливает в браузер колоссальные деньги, поскольку Хром – это окно во все сервисы: рекламу, почту, карты. Сафари связан с Маком и системой Эпла. Эдж, понятно, по самые уши интегрирован с Виндой, Бингом, Икс-боксом, офисным пакетом.

    Одного браузера недостаточно, нужны сервисы. Свои некоторые программы Гугл подкрепляет расширениями в Хроме. Так, при открытии Гугло-дока Хром молча ставит расширения Google Docs, Google Sheets и другие. Так они работают быстрее. А с чем интегрироваться Фаерфоксу?

    На рынке могут появиться разработки для снобов вроде Вивальди. Они обязательно снискают своего пользователя, но никогда не выйдут на массовый рынок. Потенциально новый браузер могут предложить только гиганты вроде Амазона или Фейсбука.

    Лично мои претензии к Фаерфоксу в том, что во-первых, он работает медленней Хрома. Пусть меньше загружены процессор и память, мое время дороже. Второе, Фаерфокс весь из себя для гиков. Это слегка умиляет, но в долгосрочной перспективе раздражает, потому что браузер нужен не только для работы, но и развлечения.

  • Реальность

    В плане профессии я знаю очень мало.

    На Си и Ассемблере писал только в институте, уже ничего не помню.

    Плаваю в указателях, памяти, куче. Код на C++ не понимаю.

    О нейросетях и машин-лернинге имею самые смутные представления.

    Про Биткоин читал только пару бульварных статей.

    Знаю три команды из Гита. Путаю, что куда мерджить.

    Накупил книжек, поставил пылиться на полку.

    Не напишу и пяти строчек на баше без интернетов.

    Не дочитал SICP. Хотел сделать все практические задания, слился.

    Кнут только в самых влажных мечтах.

    Компилятор или язык в жизни не осилю.

    Пилил свой проект, бросил.

    Пишу на Кложе, но в Джаве ни бум-бум. На Кложе потому, что язык редкий.

    Всю жизнь клепаю формочки к базе.

    Ненавижу JS, потому что не понимаю его, или наоборот.

    Емакс и Лисп чтобы быть не таким как все.

    Говорю по-английски с проблемами. Ссу провести вебинар на английском.

    Гуглю каждый HTML-тег. Копипащу со Стека.

    Умничаю в чате. Высмеиваю мессаджеры, сам же ими пользуюсь.

    Не знаю нужных хоткеев, не ставлю клевых программ.

    Не трекаю время, непродуктивен. Прокрастинирую.

    Разбираюсь только в Лиспе, Мейк-файлах и немного в Постгресе. Тем и живу.

  • Conditional Queries in Datomic

    Let’s discuss one thing related to Clojure and Datomic that might be a bit tricky especially if you have never faced it so far.

    Imagine you return a list of some entities to the client. It might be a list of orders, visits or whatever. Usually, when a user is exploring a long list of something, it’s a good idea to let them filter the list by some criteria: price, date, age etc. On the server side, you need to apply those filters conditionally. Say, when the date_from query string parameter is passed, you apply it to the query as well or live it untouched otherwise. The same for the rest of filters.

    Things become more tough when filters are applied to foreign entities. For example, an order references a user, and a user references a department. If the department_name parameter has been passed, you should join all the required tables and filter a proper field.

    Joining all the tables even if no filters were supplied is a wrong approach. Join operations are expensive and should never be performed in vain. A toolset that joins tables should take into account which ones has already been added into a query and never link them twice.

    Such systems that control the cases mentioned above are names ORMs. They are pretty complicated and full of ugly code and implicit hacks. But on the top level they behave quite friendly. Say, in Django (a major Python framework) I would perform something like that:

    query = models.Order.objects.all()
    department_name = request.query_string.get("department_name")
    if department_name:
        query = query.filter(user__department_name=department_name)
    

    Looks pretty neat. This automatically joins users and department tables under the hood using inner join SQL clause and puts department.name = ? condition into where section.

    People who work with such non-wide spreaded languages as Clojure usually do not use ORMs. But still, we need to build complicated queries. The community offers a handful of libraries (YeSQL, HoneySQL, Korma) where a query is being constructed within data structures: vectors, maps. I’ve been always against that approach. Before getting more experienced with Clojure, I felt uncomfortable constructing nested vectors like this one:

    [:select [:foo :bar]
     :from :test
     :where [(= :name "test")
             (when age-param
               (> :age age-param))]]
    

    The reason why I do not appreciate that is I cannot see the final query behind brackets and colons. It will definitely fail me once I need to express something like this (a fragment of a query from production):

    select
      foo.id,
      array_agg(foo.type_id) filter (where foo is not null) as type_ids,
      foo.name
    from foo_table as foo
    

    Modern libraries say it’s easy and fun to express your queries through data structures, but it is not, really. It becomes a challenge when applying multiple conditions to a data structure without seeing the final result.

    A good approach might be using a templating system. Say, HugSQL library allows to inject Clojure snippets into your SQL query. Those snippets are surrounded with standard SQL comments so they do not break syntax. There won’t be an error if you copy and paste such a Clojure-instrumented query into some RDBS administration tool.

    Here is an example of declaring such a query:

    -- :name get-oreders :?
    select o.*
    from oreders o
    /*~ (when (:department-name params) ~*/
    join user u on o.user_id = u.id
    join departments d on user.department_id = d.id
    where
        d.name = :department-name
    /*~ ) ~*/
    order by o.created_at;
    

    Than it compiles into a Clojure function:

    ;; no joins, no filters
    (get-oreders db)
    
    ;; causes joins and filtering
    (let [dep-name (-> request :params :department-name)]
      (get-oreders db {:department-name dep-name}))
    

    You may go further and include Selmer, a template library inspired by Django. It’s aimed at HTML rendering first but still may be used for any kind of documents including SQL.

    As I see it, a good templating system would be enough to generate SQL that fits your business logic.

    Now I’d like to discuss the same problem when using Datomic instead of classical RDBS solutions. All the tutorials that I have read do not cover a case then you need to apply several filters to a query. Suddenly, it may really become a problem. Let’s return to our example with orders. Once you don’t have any filters, the query looks simple:

    '[:find (pull ?o [*])
      :in $
      :where [?o :order/number]]
    

    But if you’ve got a department name you need to:

    1. inject a new parameter into :in section;
    2. inject additional clauses into :where section;
    3. prevent joining a user or a department entities twice if you need to filter by other department or user field.

    As we’ve seen, once you have a template system, you may render SQL queries as well. All you need is to write a query, test how does it behaves and then wrap some parts of it with special conditional tags.

    In Datomic, a query is usually a vector of symbols. Moreover, an immutable one. Thus, you cannot modify a query and adding something in the middle of it would be difficult. Surely you could wrap a query into an atom or track indexes where to inject a new item somehow but all of that would be a mess.

    What I propose is using a special kind of a query represented as a map with :find, :where and other keys. As the Datomic documentation says, when processing a query, every vector is turned into a map anyway. If we had a map, it would be easier to inject new items into it.

    To avoid wrapping a map with an atom or redefining it continuously inside let clause, there is a great form named cond->. It is a mix of both threading macro and cond clause. It takes an initial value and a bunch of predicate/update pairs. If a predicate form evaluates in true, an update form is fired using the standard threading macro. Thus, an update form should be either a function or a list where the second argument is missing and will be substituted with a value from a previous pair.

    What’s the most interesting about cond-> is unlike cond or case forms, its branches are evaluated continuously. Each update form takes a value that a previous form has produced. In other terms, an initial value goes through multiple updates without being saved in some temporary variable.

    In example below, I’ve got a data set that consists from user, location and visit entities. Both user and location are simple ones and store just dates, strings and so on. A visit is a bit more complex. It means that a user has visited a location and assigned a mark to it. Therefore, a visit references a user and a location entities as well.

    The goal is to get an average mark for a specific location. In addition, such a value might be filtered by user’s age, gender or location’s country name. Those parameters come from a query string and could be either totally skipped, passed partially or completely. I’ve got a function that accepts a location id and a map of optional parameters:

    (defn location-avg
      [location-id {:keys [fromDate
                           toDate
                           fromAge
                           toAge
                           gender]}]
    
    

    The initial Datomic query:

    (def query-initial
      '{:find [(avg ?mark) .]
        :with [?v]
        :in [$ ?location]
        :args []
        :where [[?v :location ?location]
                [?v :mark ?mark]]})
    

    Now, here is a long pipeline with comments:

    (cond-> query-initial
    
      ;; First, add two initial arguments: database instance and location reference.
      ;; This form will always be evaluated.
      true
      (update :args conj
              (get-db)                    ;; returns the DB instance
              [:location/id location-id]) ;; location reference
    
      ;; If either from- or to- date were passed, join the `visit` entity
      ;; and bind its `visited_at` attribute to the `?visited-at` variable.
      (or fromDate toDate)
      (update :where conj
              '[?v :visited_at ?visited-at])
    
      ;; If the `fromDate` filter was passed, do the following:
      ;; 1. add a parameter placeholder into the query;
      ;; 2. add an actual value to the arguments;
      ;; 3. add a proper condition against `?visited-at` variable
      ;; (remember, it was bound above).
      fromDate
      (->
       (update :in conj '?fromDate)
       (update :args conj fromDate)
       (update :where conj
               '[(> ?visited-at ?fromDate)]))
    
      ;; Do the same steps for the `toDate` filter,
      ;; but the condition slightly differs (< instead of >).
      toDate
      (->
       (update :in conj '?toDate)
       (update :args conj toDate)
       (update :where conj
               '[(< ?visited-at ?toDate)]))
    
      ;; To filter by user's fields, we bind a user reference
      ;; to the `?user` variable:
      (or fromAge toAge gender)
      (update :where conj
              '[?v :user ?user])
    
      ;; If from/to age filters we passed, bind user's age
      ;; to the `?birth-date` variable.
      (or fromAge toAge)
      (update :where conj
              '[?user :birth_date ?birth-date])
    
      ;; Then add placeholders, arguments and where clauses
      ;; for specific filters: fromAge, if passed...
      fromAge
      (->
       (update :in conj '?fromAge)
       (update :args conj (age-to-ts fromAge))
       (update :where conj
               '[(< ?birth-date ?fromAge)]))
    
      ;; ...and the same for toAge.
      toAge
      (->
       (update :in conj '?toAge)
       (update :args conj (age-to-ts toAge))
       (update :where conj
               '[(> ?birth-date ?toAge)]))
    
      ;; To filter by gender, bind user's gender to a variable
      ;; and add a clause:
      gender
      (->
       (update :in conj '?gender)
       (update :args conj gender)
       (update :where conj
               '[?user :gender ?gender]))
    
      ;; The final step is to remap a query (see below).
      true
      remap-query)
    

    Remapping a query is important because the initial data is a bit wrong. The proper structure for a map query looks as follows:

    {:query <query-map>
     :args [db location_id fromDate ...]}
    

    In my case, I believe it’s simpler to keep one-level map rather than deal with two levels (:query first, then :args). It would force me to use update-in instead if just update and write more code. Here is the remap-query function:

    (defn remap-query
      [{args :args :as m}]
      {:query (dissoc m :args)
       :args args})
    

    Finally, let’s check our results. If somebody passes all the filters, the query will look like:

    {:query
     {:find [(avg ?mark) .],
      :with [?v],
      :in [$ ?location ?fromDate ?toDate ?fromAge ?toAge ?gender],
      :where
      [[?v :location ?location]
       [?v :mark ?mark]
       [?v :visited_at ?visited-at]
       [(> ?visited-at ?fromDate)]
       [(< ?visited-at ?toDate)]
       [?v :user ?user]
       [?user :birth_date ?birth-date]
       [(< ?birth-date ?fromAge)]
       [(> ?birth-date ?toAge)]
       [?user :gender ?gender]]},
     :args [<db-object> [:location/id 42] 1504210734 1504280734 20 30 "m"]}
    

    Now you pass it into datomic.api/query function that accepts a map-like query. In my case, the result is something like: 4.18525.

    As you have seen, composing complicated queries with Datomic might a bit tricky due do immutability and differences between string templates and data structures. But still, Clojure provides rich set of tools for processing collections. In my case, the standard cond-> has made all the pipeline. Neither atoms nor other tricks to track the state were required. There is a common rule: once you’ve got stuck with a data structure, keep yourself from inventing “smart” ways to deal with it. There is probably a built-in macro in clojure.core for that.

Страница 1 из 34