• Подсветка в Телеграме

    В Телеграме появилась подсветка ссылок и цитат. Теперь у нас синенькое, зелененькое, розовенькое, голубенькое сразу вместе, одно за другим. Ну и уродские шрифты в плашках Гитхаба.

    Молодцы, старались. Один вопрос – зачем? Чтобы что?

  • Восточные сказки

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

    Наиболее интересное наблюдение — это психотип древних людей. Они в буквальном смысле большие дети. Население Персии пребывает в трех состояниях: радость, гнев, страх. Переход от одной стадии к другой случается мнговенно как у психически больных.

    Вот персонаж поел и выпил, ему хорошо. В следующую минуту ему возразил слуга; теперь он кричит и требует отрубить голову. Появляется визирь, он впадает в страх и плачет. Все это — за считанные минуты.

    Герои никогда не говорят спокойно. Малейшее несогласие или возражение — и они кричат, плачут, словом, что угодно, лишь бы не спокойно обсудить решение.

    Еще одно наблюдение — герою не зазорно делать подлости. Может быть, вы забыли, чем кончается оригинальная сказка про Аладдина, но я напомню. Он пробирается в замок врага, который похитил принцессу. Они с принцессой договариваются: та соблазнит злодея и усыпит сонным порошком, а Алладдин убъет. Так и выходит: Аладдин стоит за шторой, и когда злодей падает сонный, спокойно рубит ему голову.

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

    Чтобы два раза не вставать: в оригинале Аладдина зовут Ала Ад’Дин. Такое вот сложное имя, которое упростили для иностранного читателя.

  • Сайт взломали

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

    Чтобы сайты не взламывали, их устойчивость должна быть заложена в архитектуру. Чем больше в ней уровней, тем больше уязвимостей на сайте. Возмите блог на Вордпрессе: это Линукс, Апач, PHP, MySQL и JavaScript. Вместе они ведут себя как клубок змей. У каждой технологии свои примочки, уязвимости (известные и пока еще нет), конфиги и настройки. Вероятность, что все они настроены правильно, редко бывает стопроцентной.

    Наверное, вы думаете, что хакеры — это гении в очках и плащах, как в Матрице. Они знают машкоды, решают крипто-хеши на бумажке и все такое. Это не так. Современные хакеры — это мальчики, которые в лучшем случае знают Питон или баш, чтобы написать цикл. Их работа сводится к тому, чтобы натравить на сайт опасный скрипт. Если известно, что сайт сделан на CMS версии X, и она устарела хотя бы на год, то не сомневайтесь — сайт работает лишь потому, что еще не привлек внимания.

    Я пишу это к тому, что безопасность сайта обеспечивается его статичностью. Есть набор md-файлов, и есть скрипт, который собирает статичный сайт. Это папка с index.html и подпапками, где разложены статьи. Такой сайт можно хостить хоть в S3, хоть на домашнем роутере. Сломать его можно одним способом — украсть SSH-ключ или AWS-креды, что к самому сайту не имеет отношения.

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

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

    Много лет назад мой блог работал на Эгее Ильи Бирмана. Это класическая связка Apache + PHP + Mysql. Сколько же я натерпелся с ним! Хостер без конца менял настройки PHP, и на главной были машинные ворнинги. Как можно жить, опасаясь, что на главной какая-то дичь, а бекап базы не сделался?

    После переезда на Jekyll вздохнул спокойно. Статичный сайт после генерации не может испортиться. Он будет такой же и завтра, и через десять лет. Хостить его можно где угодно, даже без Апача и PHP.

    Хорошо, а как обновлять на сайте информацию, например, тарифы или адреса отделений? Очень просто: каждую ночь из системы выгружается JSON или CSV с тарифами. В исходниках сайта делают шаблон, который пробегает по строкам и красиво их рендерит. На выходе чистый HTML, все довольны. Билд можно запустить принудительно, если горит.

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

  • Письма от Госуслуг

    У Госуслуг все печально с письмами:

    После “здравствуйте” должен быть восклицательный знак. В конце предложений — точка. Я видел много безграмотных писем — без запятых и с опечатками, — но чтобы забывать точки, это в первый раз.

    Кроме того, если мне звонят мошенники, я еще не стал их жертвой. А то выходит, я становлюсь жертвой каждый второй день. Термина “жертва” вообще лучше избегать в переписке. Никто не хочет быть жертвой, а Госуслуги уже признали тебя ей заочно.

    Копирайтера, который составил этот текст, — на мыло.

    UPD: читатели сообщили, что все так и задумано. Вот жесть.

  • Разбиение дисков

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

    Каждый из нас знал правило: жесткий диск нужно разбить на разделы. Только лошара покупает жесткий на 500 гигабайт и ставит на него винду. Правильные ребята делают разделы как минимум под винду и мультимедиа.

    Эта привычка оказалась так сильна, что я разбивал диски даже во взрослом возрасте, хотя понимал, что в этом нет смысла.

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

    Таким образом, причина этой дурацкой привычки — нестабильность операционной системы.

    Есть и вторая причина. Винда строит свой интерфейс так, что во главе стоят диски. Если открыть “Мой компьютер”, там будут диски, и только потом уже папки. Файловые менеджеры вроде Total Commander и Nornon Commander тоже были завязаны на диски. Если вдуматься, то Windows 95 была лишь графической оберткой над MS-DOS, а DOS означает Disk Operating System — система управления дисками. Так и получилось, что принцип DOS — завязка на диски — докатился до наших дней.

    С точки зрения пользователя диск — это супер-папка верхнего уровня. Чтобы облегчить навигацию по файлам, нужно создать больше таких супер-папок. Отсюда привычка разбивать диск на C:, D:, E:, Z: и так далее. Прироста скорости нет, потому что это одно физическое устройство. Но диски все равно разбивают.

    Все это я пишу, чтобы сравнить ситуацию с Линуксом или Маком. За годы работы с ними я ни разу не разбивал диск. Это кажется нелепым: в системе единое дерево каталогов, и очередной диск — это папка /Volumes/foobar. Нет никакого смысла что-то разбивать, хоть это и возможно технически.

    Если говорить о медиафайлах, то на Линуксе и Маке не нужно хранить дистрибутивы. Все ставится из пакетов. За коммерческий софт проще заплатить. Игры — либо Стим, либо торренты. Фотографии и личные файлы лежат во всяких дропбоксах и гугло-драйвах. Нет смысла хранить все это выделенном разделе, опасаясь, как бы не потерять при переустановке винды.

    Из этого следует: если пользователи винды разбивают диски, дело в недостатках винды. Это сигнал того, что поведение по умолчанию не устраивает пользователей. Вместо того, чтобы работать, и они занимаются ерундой: что-то разбивают и переустанавливают.

    Так быть не должно.

  • Браузер Arc

    Появился “элитный” браузер исключительно под MacOS — Arc. Обещают невиданые красоты, интеграцию с операционкой, все дела. При первом запуске он устраивает натуральный пафос: играет музыку, показывает ролик с переливанием фигур. Каламбия Пикчерз представляет.

    Что внутри, я так и не узнал: бразуер не работает без учетной записи. Поэтому отправляется в корзину — мне такого браузера не надо. Заодно отмечу вес этого поделия — 750 мегабайт.

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

  • Неудавшийся собес

    История о том, я как проходил череду собеседований, но не взяли.

    Несколько месяцев назад я подался в американскую фирму. Делают продукт на Кложе, компетенция в разработке есть. Начали не вчера, пилят уже девять лет. Не “молодая, динамично развивающая” компания, а нормальная.

    Пришел машинный ответ, что мы с вами свяжемся, на чем я и забыл. И вот на днях всплыли с предложением пройти собес. И не просто собес, а серию из четырех интервью с кодигом, а в конце, если повезет, с финальным боссом — CTO.

    Компания из Калифорнии и косит под FAANG. Обычный собес — не их метод. Сперва хотел отказаться, но подумал — что я теряю? Я ни разу не собеседовался в FAANG, только разговаривал с рекрутерами. Почему бы не попробовать новый формат? Согласился, назначили звонки.

    Первый собес на тридцать минут. Приветствие, кто такой, чем живешь. Далее кодинг: задача на размен монет. Дается сумма и достоинства монет, надо определить, сколько каких монет дать. Возвращается мапка, где ключ — достоинство монеты, значение — количество.

    Во время кодинга собеседник молчал. Я рассуждал вслух, проговаривал алгоритм. Даже когда я попросил подсказать имя функции, он не ответил. Было ощущение, что он смотрит в браузер по своим нуждам.

    Ладно, вспоминаю жадный алгоритм размена, он не такой сложный. Но есть нюанс: он работает только если среди монет есть достоинством в одну. Это нужно, чтобы добить остаток, когда не осталось других монет. А один из кейсов был подобран так, что монеты достоинством в одну не было.

    Это ломает весь алгоритм, и получается задача “два в одном”. Я затупил и стал думать, что проще: воткнуть костыль или писать другой алгоритм на перебор или с общими множителями. Пока я думал, собеседник сказал, что ему пора на другой звонок, а я могу выслать решение позже. И отключился.

    Посидев еще минут десять, я выслал решение с костылем, который решал последний кейс. Тратить остаток вечера на все это я не хотел.

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

    Итак, первый часовой звонок, собеседник из Венгрии. Приветствие, интродакшен. Задача: написать суперсет множества. Это когда для элементов {1, 2, 3} возвращаешь {1, 2 , 3}, {1, 2}, {2, 3}, {1, 3}, {1}, {2}, {3}, {}. Очевидно, это решается рекурсией и очередью, но первые пять минут я тупил, не понимая, что передавать между итерациями.

    Потом меня осенило и я выдал решение на tree-seq. Не все знают про эту штуку, это встроенная фишка Кложи. Позволяет разложить любую структуру на последовательность. Собеседник сказал, что ни разу с ней не работал и удивлен решением. По реакции я понял, что он колеблется, но вроде бы склоняется в мою сторону.

    Я импортировал модуль тестов и написал тесты на все случаи из задачи — они проходили.

    Далее собеседник велел спроектировать функцию для некоторой бизнес-задачи. Нужно определить обязательные и опциональные параметры, подобрать имя, написатаь докстринг, учесть несколько вариаций. Все это я делал в одном опенсорс-проекте, поэтому написал скелет функции, а дальше открыл Гитхаб и показал код. Собеседник был доволен, и на этом мы закончили.

    Проходит полчаса, новый звонок. Собеседник из Штатов. Какие были ваши главные челенджи? Открываю драйвер для Постгреса, там каждая строчка — челендж. Чтение и парсинг байтов, стейт-машина для воркфлоу, проброс состояния в дальние концы кода. Он смотрит, кивает.

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

    Я пишу код, который работает на простых примерах. Собеседник дает огромный граф, и на нем код падает. После пары подсказок я делаю фикс, и код проходит. Жаль, что что не повторил графы раньше, тем более что книга с графами лежала под рукой, и там даже была закладка. Показываю свою библиотеку для зипперов, где сделан обход в ширину и поиск по предикату. Собеседник доволен, прощаемся.

    Третий часовой звонок, два собеседника из Европы. Как обычно, привествие, интродакшен и задача: написать интерпретатор Кложи. Это функция ev, которая может выполнять код, определять переменные, делать ветвление if/else, лексические переменные, функциии, замыкания на функциях, и многое другие. Pdf с описанием был на четыре страницы.

    Кому-то это покажется сложным, но для меня это было самой простой задачей. Интерпретатор подробно рассмотрен в SICP, и та глава врезалась в самую подкорку. Я сделал вычисление форм, арифметику, сравнение, а также две особые формы: глобальные переменные и let. Единственное место, где я втупил, было разделение контекста. Ясно, что должен быть глобальный контекст, чтобы объявление переменной в одном ev действовало на второй. Кроме этого нужен локальный контекст для let, который пробрасывается как мапа. В процессе резолва оба контекса мерджатся.

    Собеседники постоянно кивали на мои высказывания. Показывали большой палец. В конце обсудили потенциальные улучшения кода, что я написал. Разошлись довольные.

    Я стал ждать финального босса, и вдруг приходит письмо от рекрутера. Иван, мы тут подумали и решили, что продолжать нет смысла. Очень коротко, буквально два предложения. Я вежливо поинтересовался подробностями, но понятно, что никто не ответил.

    Ну и что это было? До сих пор не могу понять. Какой-то цирк. Не знаю, как справились другие, но вряд ли сильно лучше меня. Хоть я и тупил, но в итоге все задачи выполнил. Может, параллельно со мной собеседовались маньяки из Advent of Code? Почему тогда не сказать прямо: другой кандидат справился лучше и мы выбрали его?

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

    Почитать бы им правила деловой переписки Ильяхова с Сарычевой или что-то из их блогов. Неужели нет англоязычных ресурсов, где написано об уважении к собеседнику? Видимо, нет.

    Что можно вынести из этой истории? Собеседование — это по-прежнему лотерея. Я готовился к system design интервью, а был кодинг. Можно готовить графы, а попадутся деревья. Спросят про главный челендж — а ты забыл про удачный случай и говоришь про неудачный.

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

    Хорошо искать работу, когда уже есть работа. В этом случае отказ, даже грубый, не вопринимается остро.

    Никогда не узнаешь, чем ты не подошел. Тем, что решил плохо? Тем, что решил слишком хорошо? Много улыбался? Мало улыбался? Потому что русский? Потому что нерусский?

    Все это в чужих головах, куда нет доступа. А потому — написал в бложик, отпустило, забылось. Едем дальше!

  • PG docs, part 8. HoneySQL

    (This is a new documentation chapter from the PG project.)

    ToC

    In this chapter:

    HoneySQL is a well-known library for building SQL expressions from Clojure maps. It’s convenient for making complex queries, for example, when you have optional JOIN operators. Or you’re unaware of the final WHERE conditions as the filtering parameters come from the request. HoneySQL frees you from building raw SQL queries by concatenating strings, which is unsafe and leads to SQL injections.

    The pg-honey is a small wrapper on top of HoneySQL. It provides special versions of query and execute functions that accept not a SQL string but Clojure maps. The maps get transformed into SQL under the hood and get executed.

    Installation

    Install the pg-honey package as follows.

    Lein:

    [com.github.igrishaev/pg-honey "0.1.10"]
    

    Deps:

    {com.github.igrishaev/pg-honey {:mvn/version "0.1.10"}}
    

    Usage

    Import the library:

    (require '[pg.honey :as pgh])
    

    The query function accepts a connection object, a Clojure map representing a query and a map of options.

    (pgh/query
      conn
      {:select [:*] :from [:users]}
      {:pretty true
       :as as/first})
    

    The third parameter combines HoneySQL parameters and the standard query options. In the example above, we passed a custom reducer into the :as parameter, and we also specified the :pretty HoneySQL option to true. With the pretty flag enabled, HoneySQL produces a formatted SQL expression, which is easier to read in logs.

    Please note: since the query function doesn’t allow you to pass any parameters, the following example will lead to an error response:

    (pgh/query
      conn
      {:select [:*] :from [:users] :where [:= :id 42]}
      {:pretty true
       :as as/first})
    

    This is a limitation of the PostgreSQL wire protocol: the Query message bears only a pure SQL expression with no parameters. For parameters, use the execute function described below.

    The execute function acts the same but accepts a Clojure map that might have values that become parameters when rendering the map. Here is an example:

    (pgh/execute
      conn
      {:select [:*] :from [:users] :where [:= :id 42]}
      {:pretty true
       :as as/first})
    

    Or:

    (pgh/execute
      conn
      {:insert-into :demo
       :values [{:id 1 :title "test1"}
                {:id 2 :title "test2"}
                {:id 3 :title "test3"}]}
      {:pretty true})
    

    Under the hood, the {:inset-into ...} map gets rendered into a SQL vector:

    ["insert into ... values ($1, $2), ($3, $4), ($5, $6)"
     1 "test1" 2 "test2" 3 "test3"]
    

    It gets split on the SQL expression and the parameters, which are passed into the underlying pg.client/execute function.

    You can use named parameters that HoneySQL does support. Place a specific keyword into the [:param ...] vector, and pass a map of params into the options as follows:

    (pgh/execute conn
                 {:select [:id :title]
                  :from [:demo]
                  :where [:and
                          [:= :id 2]
                          [:= :title [:param :title]]]}
                 {:pretty true
                  :params {:title "test2"}})
    

    To familiarise yourself with HoneySQL features, please refer to the official documentation.

  • Virtuoso: a Clojure wrapper for virtual threads

    Virtuoso is small wrapper on top of virtual threads introduced in Java 21.

    About

    The recent release of Java 21 introduced virtual threads to the scene. It’s a nice feature that allows you to run imperative code, such as it was written in an asynchronous way. This library is a naive attempt to gain something from the virtual threads.

    Installation

    Lein

    [com.github.igrishaev/virtuoso "0.1.0"]
    

    Deps/CLI

    {com.github.igrishaev/virtuoso {:mvn/version "0.1.0"}}
    

    Usage

    First, import the library:

    (require '[virtuoso.core :as v])
    

    with-executor

    The with-executor wraps a block of code binding a new instance of VirtualThreadPerTaskExecutor to the passed symbol:

    (v/with-executor [exe]
      (do-this ...)
      (do-that ...))
    

    Above, the executor is bound to the exe symbol. Exiting from the macro will trigger closing the executor, which, in turn, leads to blocking until all the tasks sent to it are complete. The with-executor macro, although it might be used on your code, is instead a building material for other macros.

    future-via

    The future-via macro spawns a new virtual future through a previously open executor. You can generate as many futures as you want due to the nature of virtual threads: there might be millions of them.

    (v/with-executor [exe]
      (let [f1 (v/future-via exe
                 (do-this ...))
            f2 (v/future-via exe
                 (do-that ...))]
        [@f1 @f2]))
    

    Virtual futures give performance gain only when the code they wrap makes IO. Instead, if you run CPU-based computations in virtual threads, the performance suffers due to continuations and moving the stack trace from the stack to the heap and back.

    futures(!)

    The futures macro takes a series of forms. It spawns a new virtual thread executor and wraps each form into a future bound to that executor. The result is a vector of Future objects. To obtain values, pass the result through (map/mapv deref ...):

    (let [futs
          (v/futures
           (io-heavy-task-1 ...)
           (io-heavy-task-2 ...)
           (io-heavy-task-3 ...))]
      (mapv deref futs))
    

    Right before you exit the macro, it closes the executor, which leads to blicking until all the tasks are complete.

    Pay attention that deref-ing a failed future leads to throwing an exception. That’s why the macro doesn’t dereference the futures for you, as it doesn’t know how to handle errors. But if you don’t care about exception handling, there is a futures! macro that does it for you:

    (v/futures!
      (io-heavy-task-1 ...)
      (io-heavy-task-2 ...)
      (io-heavy-task-3 ...))
    

    The result will be vector of dereferenced values.

    thread

    The thread macro spawns and starts a new virtual thread using the (Thread/ofVirtual) call. Threads in Java do not return values; they can only be join-ed or interrupted. Use this macro when interested in a Thread object but not the result.

    (let [thread1
          (v/thread
            (some-long-task ...))
    
          thread2
          (v/thread
            (some-long-task ...))]
    
      (.join thread1)
      (.join thread2))
    

    pmap(!)

    The pmap function acts like the standard clojure.core/pmap: it takes a function and a collection (or more collections). It opens a new virtual executor and submits each calculation step to the executor. The result is a vector of futures. The function closes the executor afterwards, blocking until all the tasks are complete.

    (let [futs
          (v/pmap get-user-from-api [1 2 3])]
      (mapv deref futs))
    

    Or:

    (let [futs
          (v/pmap get-some-entity                ;; assuming it accepts id and status
                  [1 2 3]                        ;; ids
                  ["active" "pending" "deleted"] ;; statuses
                  )]
      (mapv deref futs))
    

    The pmap! version of this function dereferences all the results for you with no exception handling:

    (v/pmap! get-user-from-api [1 2 3])
    ;; [{:id 1...}, {:id 2...}, {:id 3...}]
    

    each(!)

    The each macro is a wrapper on top of pmap. It binds each item from a collection to a given symbol and submits a code block into a virtual executor. The result is a vector of futures; exiting the macro closes the executor.

    (let [futs
          (v/each [id [1 2 3]]
            (log/info...)
            (try
              (get-entity-by-id id)
              (catch Throwable e
                (log/error e ...))))]
      (is (= [{...}, {...}, {...}] (mapv deref futs))))
    

    The each! macro acts the same but dereferences all the futures with no error handling.

    Measurements

    There is a development dev/src/bench.clj file with some trivial measurements. Imagine you want to download 100 of URLs. You can do it sequentially with mapv, semi-parallel with pmap, and fully parallel with pmap from this library. Here are the timings made on my machine:

    (time
     (count
      (map download URLS)))
    "Elapsed time: 45846.601717 msecs"
    
    (time
     (count
      (pmap download URLS)))
    "Elapsed time: 3343.254302 msecs"
    
    (time
     (count
      (v/pmap! download URLS)))
    "Elapsed time: 1452.514165 msecs"
    

    45, 3.3, and 1.4 seconds favour the virtual threads approach.

    The following links helped me a lot to dive into virtual threads, and I highly recommend reading and watching them:

  • PG docs, part 7. COPY IN/FROM

    (This is a new documentation chapter from the PG project.)

    ToC

    In this chapter:

    Theory

    The recent update of pg-client library introduces various ways to COPY the data into or from the database. It’s much more flexible than the official JDBC Postgres driver’s standard CopyManager class.

    To remind you, COPY is a massive way of writing or reading data. Copying IN is much faster than inserting the rows by chunks. Postgres starts to read the data immediately without waiting for the last bit of data to arrive. You can copy into the same table in parallel threads. The same applies to copying out: if you want to dump a table into a file, use COPY FROM with an OutputStream OutputStream rather than selecting everything in memory.

    The main disadvantage of JDBC CopyManager is, that it doesn’t do anything about data encoding and encoding. It accepts either an InputStream or an OutputStream assuming you encode the data on your own. It means, right before you copy the data to the database, you’ve got to manually encode them into CSV.

    This is not as easy as you might think. When encoding values into CSV, it coerces everything to a string using str. That’s OK for most of the primitive types as numbers, booleans or strings: their Clojure representation matches the way they’re represented in Postgres. But it doesn’t work for complex types like arrays. If you write a vector of [1 2 3] in CSV you’ll get "[1 2 3]" which is an improper Postgres value. It must have been {1, 2, 3} instead.

    Another flaw of JDBC CopyManager is, that it doesn’t split the data by rows when sending them into the database. It simply reads 2Kb of bytes from an InputStream and writes them to a socket. At the same time, the PostgreSQL documentation recommends splitting the data chunks by rows:

    The message boundaries are not required to have anything to do with row boundaries, although that is often a reasonable choice

    Moreover, PostgreSQL supports not only CSV but also text and binary formats. The text format is somewhat CSV with different separators so it’s not so important. But the binary format is indeed! Binary-encoded data are faster to parse and process and thus are preferable when dealing with vast chunks of data.

    CSV vs Binary

    Here are a couple of measurements I made on my local machine. I made two files containing 10 million rows: in CSV and in binary format. Then I used the official CopyManager to copy these files in the database. All the server settings were default; the machine was an Apple M1 Max 32Gb with 10 Cores.

    Single thread COPY

    Rows Format Time, sec
    10M binary 17.4
    10M CSV 51.2

    Parallel COPY

    Binary:

    Rows Threads Chunk Format Time, sec
    10M 8 10k binary 11.3
    10M 4 10k binary 13.7
    10M 1 10k binary 28.6

    CSV:

    Rows Threads Chunk Format Time, sec
    10M 8 10k CSV 10.6
    10M 4 10k CSV 19.9
    10M 1 10k CSV 71.7

    It’s plain to see that binary encoding is three times faster than CSV. 17 vs 51 seconds is a significant difference one cannot ignore.

    The good news is, the PG library does support binary encoding. It also allows you to perform COPY operations without encoding them manually. The library doesn’t make any InputStreams in the background: it encodes the rows one by one and sends them directly into the database. It also supports binary format of encoding which is a matter of passing a parameter. Also, it does split the data chunks by rows, not by the size of the buffer.

    Usage

    Establish a connection to the database first:

    (require '[pg.client :as pg])
    
    (def conn (pg/connect {...}))
    

    COPY out

    The copy-out function dumps a table or a query into a file. It accepts a connection object, a SQL expression describing the table, the columns, the format and other details, and an instance of an OutputStream. The rows from the table or a query get sent to that stream. The function returns a number of rows processed.

    (let [sql
          "COPY (select s.x as x, s.x * s.x as square from generate_series(1, 9) as s(x))
          TO STDOUT WITH (FORMAT CSV)"
    
          out
          (new ByteArrayOutputStream)]
    
      (pg/copy-out conn sql out))
    

    The expression above returns 9 (the number of rows). The actual rows are now in the out variable that stores bytes.

    Of course, for massive data it’s better to use not ByteArrayOutputStream but FileOutputStream. You can produce it as follows:

    (with-open [out (-> "/some/file.csv"
                        io/file
                        io/output-stream)]
      (pg/copy-out conn sql out))
    

    The PG library doesn’t close the stream assuming you may write multiple data into a single stream. It’s up to you when to close it.

    To dump the data into a binary file, add the WITH (FORMAT BINARY) clause to the SQL expression. Binary files are more difficult to parse yet they’re faster in processing.

    COPY IN from stream

    The copy-in function copies the data from in InputStream into the database. The payload of the stream is either produced by the previous copy-out function or manually by dumping the data into CSV/binary format. The function returns the number or rows processed by the server.

    (def in-stream
      (-> "/some/file.csv" io/file io/input-stream))
    
    (pg/copy-in conn
                "copy foo (id, name, active) from STDIN WITH (FORMAT CSV)"
                in-stream)
    
    ;; returns 6
    

    Again, it doesn’t close the input stream. Use the with-open macro to close it explicitly.

    The next two functions are more interesting as they bring functionality missing in the JDBC.

    COPY IN rows

    The copy-in-rows function takes a sequence of rows and sends them into the database one by one. It doesn’t do any intermediate steps like dumping them into an InputStream first. Everything is done on the fly.

    The function takes a connection, a SQL expression, and a sequence of rows. A row is a sequence of values. The result is a number of rows copied into the database.

    (pg/copy-in-rows conn
                     "copy foo (id, name, active, note) from STDIN WITH (FORMAT CSV)"
                     [[1 "Ivan" true nil]
                      [2 "Juan" false "kek"]])
    ;; 2
    

    The fourth optional parameter is a map of options. At the moment, the following options are supported:

    name default example (or enum) description
    :sep ,   a character to separate columns in CSV/text formats
    :end \r\n   a line-ending sequence of characters in CSV/text
    :null empty string   a string to represent NULL in CSV/text
    :oids nil [oid/int2 nil oid/date], {0 oid/int2, 2 oid/date} type hints for proper value encoding. Either a vector or OIDs, or a map of {index => OID}
    :format :csv :csv, :bin, :txt a keyword to specify the format of a payload.

    Copy rows in CSV with custom column separators and NULL representation:

    (pg/copy-in-rows conn
                     "COPY foo (id, name, active, note) FROM STDIN WITH (FORMAT CSV, NULL 'NULL', DELIMITER '|')"
                     rows
                     {:null "NULL"
                      :sep \|})
    ;; 1000
    

    Copy rows as a binary payload with custom type hints:

    (pg/copy-in-rows conn
                     "COPY foo (id, name, active, note) from STDIN WITH (FORMAT BINARY)"
                     rows
                     {:format :bin
                      :oids {0 oid/int2 2 oid/bool}})
    ;; 1000
    

    COPY IN maps

    Often, we deal not with plain rows but maps. The copy-in-maps function acts but copy-in-rows but accepts a sequence of maps. Internally, all the maps get transformed into rows. To transform it properly, the function needs to know the order of the keys.

    The funtion accepts a connection, a SQL expression, a sequence of maps and a sequence of keys. Internally, it produces a selector from the keys like this: (apply juxt keys) which gets applied to each map.

    One more thing about copying maps is, that the :oids parameter is a map like {key => OID}.

    An example of copying the maps in CSV. Pay attention that the second map has extra keys which are ignored.

    (pg/copy-in-maps conn
                     "copy foo (id, name, active, note) from STDIN WITH (FORMAT CSV)"
                     [{:id 1 :name "Ivan" :active true :note "aaa"}
                      {:aaa false :id 2 :active nil :note nil :name "Juan" :extra "Kek" :lol 123}]
                     [:id :name :active :note]
                     {:oids {:id oid/int2}
                      :format :csv})
    

    Another example where we copy maps using binary format. The :oids map has a single type hint so the :id fields get transformed to int2 but not bigint which is default for Long values.

    (pg/copy-in-maps conn
                     "copy foo (id, name, active, note) from STDIN WITH (FORMAT BINARY)"
                     maps
                     [:id :name :active :note]
                     {:oids {:id oid/int2}
                      :format :bin})
    

Страница 8 из 75