• Бинарный дамп

    Простое правило: никогда не пользуйтесь бинарными дампами. Если нужно сбросить данные на диск, используйте JSON, EDN, YAML, что угодно. Но не нужно брать библиотеку, которая сериализует любой объект в байтики — это плохо кончится.

    Десять лет назад я работал в Wargaming, и для очереди задач там использовали pickle. Кто не знает, это бинарный дамп объектов PyObject. И пока был только Питон, все работало. Но потом в качестве эксперимента завезли Эрланг, и приехали: он не мог читать эти дампы. Ребятам пришлось писать парсер, который с горем пополам вытягивал оттуда данные. Потом пришлось мигрировать сообщеньки с pickle на json, не спать неделю, мониторить и все такое.

    В мире Clоjure есть похожая поделка — nippy. Это сброс чего угодно в байты с последующим чтением. Ужасная библиотека с интерфейсом а-ля “чужой для хищников”. Самое главное — с ней везде проблемы. Кастомные классы не читаются без приседаний. Ошибки игнорируются, вместо них мапы “тут что-то не то”. Стоит внедрить, как найдется потребитель с Питоном или Lua, для которых реализаций нет.

    Потерял два дня, пытаясь заставить работать nippy в Граале. Не работает. Черная магия десериализации валится со страшными трейсами.

    Хочется впасть в отчаяние: почему люди не учатся? Почему я ходил по этим граблям 10 лет назад, и меня заставили снова? Если хочется бинарей, возьми протобуф: стандарт с реализацией под все платформы. Зачем брать поделку от “опен-сорс ламы” (так автор себя называет)?

    Все это мне не понятно.

  • PG2 release 0.1.6: rich JSON capabilities

    PG2 version 0.1.6 is out, and it ships various improvements to JSON(b) handling.

    Table of Content

    Postgres is amazing when dealing with JSON. There hardly can be a database that serves it better. Unfortunately, Postgres clients never respect the JSON feature, which is horrible. Take JDBC, for example: when querying a JSON(b) value, you’ll get a dull PGObject which should be decoded manually. The same applies to insertion: one cannot just pass a Clojure map or a vector. It should be packed into the PGObject as well.

    Of course, this can be automated by extending certain protocols. But it’s still slow as it’s done on Clojure level (not Java), and it forces you to copy the same code across projects.

    Fortunately, PG2 supports JSON out from the box. If you query a JSON value, you’ll get its Clojure counter-part: a map, a vector, etc. To insert a JSON value to a table, you pass either a Clojure map or a vector. No additional steps are required.

    PG2 relies on jsonista library to handle JSON. At the moment of writing, this is the fastest JSON library for Clojure. Jsonista uses a concept of object mappers: objects holding custom rules to encode and decode values. You can compose your own mapper with custom rules and pass it into the connection config.

    Basic usage

    Let’s prepare a connection and a test table with a jsonb column:

    (def config
      {:host "127.0.0.1"
       :port 10140
       :user "test"
       :password "test"
       :dbname "test"})
    
    (def conn
      (jdbc/get-connection config))
    
    (pg/query conn "create table test_json (
      id serial primary key,
      data jsonb not null
    )")
    

    Now insert a row:

    (pg/execute conn
                "insert into test_json (data) values ($1)"
                {:params [{:some {:nested {:json 42}}}]})
    

    No need to encode a map manually nor wrap it into a sort of PGObject. Let’s fetch the new row by id:

    (pg/execute conn
                "select * from test_json where id = $1"
                {:params [1]
                 :first? true})
    
    {:id 1 :data {:some {:nested {:json 42}}}}
    

    Again, the JSON data returns as a Clojure map with no wrappers.

    When using JSON with HoneySQL though, some circs are still needed. Namely, you have to wrap a value with [:lift ...] as follows:

    (pgh/insert-one conn
                    :test_json
                    {:data [:lift {:another {:json {:value [1 2 3]}}}]})
    
    {:id 2, :data {:another {:json {:value [1 2 3]}}}}
    

    Without the [:lift ...] tag, HoneySQL will treat the value as a nested SQL map and try to render it as a string, which will fail of course or lead to a SQL injection.

    Another way is to use HoneySQL parameters conception:

    (pgh/insert-one conn
                    :test_json
                    {:data [:param :data]}
                    {:honey {:params {:data {:some [:json {:map [1 2 3]}]}}}})
    

    For details, see the “HoneySQL Integration” section.

    PG2 supports not only Clojure maps but vectors, sets, and lists. Here is an example with with a vector:

    (pg/execute conn
                "insert into test_json (data) values ($1)"
                {:params [[:some :vector [:nested :vector]]]})
    
    {:id 3, :data ["some" "vector" ["nested" "vector"]]}
    

    Json Wrapper

    In rare cases you might store a string or a number in a JSON field. Say, 123 is a valid JSON value but it’s treated as a number. To tell Postgres it’s a JSON indeed, wrap the value with pg/json-wrap:

    (pgh/insert-one conn
                    :test_json
                    {:data (pg/json-wrap 42)})
    
    {:id 4, :data 42}
    

    The wrapper is especially useful to store a “null” JSON value: not the standard NULL but "null" which, when parsed, becomes nil. For this, pass (pg/json-wrap nil) as follows:

    (pgh/insert-one conn
                    :test_json
                    {:data (pg/json-wrap nil)})
    
    {:id 5, :data nil} ;; "null" in the database
    

    Custom Object Mapper

    One great thing about Jsonista is a conception of mapper objects. A mapper is a set of rules how to encode and decode data. Jsonista provides a way to build a custom mapper. Once built, it can be passed to a connection config so the JSON data is written and read back in a special way.

    Let’s assume you’re going to tag JSON sub-parts to track their types. For example, if encoding a keyword :foo, you’ll get a vector of ["!kw", "foo"]. When decoding that vector, by the "!kw" string, the mapper understands it a keyword and coerces "foo" to :foo.

    Here is how you create a mapper with Jsonista:

    
    (ns ...
      (:import
       clojure.lang.Keyword
       clojure.lang.PersistentHashSet)
      (:require
        [jsonista.core :as j]
        [jsonista.tagged :as jt]))
    
    (def tagged-mapper
      (j/object-mapper
       {:encode-key-fn true
        :decode-key-fn true
        :modules
        [(jt/module
          {:handlers
           {Keyword {:tag "!kw"
                     :encode jt/encode-keyword
                     :decode keyword}
            PersistentHashSet {:tag "!set"
                               :encode jt/encode-collection
                               :decode set}}})]}))
    

    The object-mapper function accepts even more options but we skip them for now.

    Now that you have a mapper, pass it into a config:

    (def config
      {:host "127.0.0.1"
       :port 10140
       :user "test"
       :password "test"
       :dbname "test"
       :object-mapper tagged-mapper})
    
    (def conn
      (jdbc/get-connection config))
    

    All the JSON operations made by this connection will use the passed object mapper. Let’s insert a set of keywords:

    (pg/execute conn
                "insert into test_json (data) values ($1)"
                {:params [{:object #{:foo :bar :baz}}]})
    

    When read back, the JSON value is not a vector of strings any longer but a set of keywords:

    (pg/execute conn "select * from test_json")
    
    [{:id 1, :data {:object #{:baz :bar :foo}}}]
    

    To peek a raw JSON value, select it as a plain text and print (just to avoid escaping quotes):

    (printl (pg/execute conn "select data::text json_raw from test_json where id = 10"))
    
    ;; [{:json_raw {"object": ["!set", [["!kw", "baz"], ["!kw", "bar"], ["!kw", "foo"]]]}}]
    

    If you read that row using another connection with a default object mapper, the data is returned without expanding tags.

    Utility pg.json namespace

    PG2 provides an utility namespace for JSON encoding and decoding. You can use it for files, HTTP API, etc. If you already have PG2 in the project, there is no need to plug in Cheshire or another JSON library. The namespace is pg.json:

    (ns ...
      (:require
       [pg.json :as json]))
    

    Reading JSON

    The read-string function reads a value from a JSON string:

    (json/read-string "[1, 2, 3]")
    
    [1 2 3]
    

    The first argument might be an object mapper:

    (json/read-string tagged-mapper "[\"!kw\", \"hello\"]")
    
    :hello
    

    The functions read-stream and read-reader act the same but accept either an InputStream or a Reader object:

    (let [in (-> "[1, 2, 3]" .getBytes io/input-stream)]
      (json/read-stream tagged-mapper in))
    
    (let [in (-> "[1, 2, 3]" .getBytes io/reader)]
      (json/read-reader tagged-mapper in))
    

    Writing JSON

    The write-string function dumps an value into a JSON string:

    (json/write-string {:test [:hello 1 true]})
    
    ;; "{\"test\":[\"hello\",1,true]}"
    

    The first argument might be a custom object mapper. Let’s reuse our tagger mapper:

    (json/write-string tagged-mapper {:test [:hello 1 true]})
    
    ;; "{\"test\":[[\"!kw\",\"hello\"],1,true]}"
    

    The functions write-stream and write-writer act the same. The only difference is, they accept either an OutputStream or Writer objects. The first argument might be a mapper as well:

    (let [out (new ByteArrayOutputStream)]
      (json/write-stream tagged-mapper {:foo [:a :b :c]} out))
    
    (let [out (new StringWriter)]
      (json/write-writer tagged-mapper {:foo [:a :b :c]} out))
    

    Ring HTTP middleware

    PG2 provides an HTTP Ring middleware for JSON. It acts like wrap-json-request and wrap-json-response middleware from the ring-json library. Comparing to it, the PG2 stuff has the following advantages:

    • it’s faster because of Jsonista, whereas Ring-json relies on Cheshire;
    • it wraps both request and response at once with a shortcut;
    • it supports custom object mappers.

    Imagine you have a Ring handler that reads JSON body and returns a JSON map. Something like this:

    (defn api-handler [request]
      (let [user-id (-> request :data :user_id)
            user (get-user-by-id user-id)]
        {:status 200
         :body {:user user}}))
    

    Here is how you wrap it:

    (ns ...
      (:require
       [pg.ring.json :refer [wrap-json
                             wrap-json-response
                             wrap-json-request]]))
    
    (def app
      (-> api-handler
          (wrap-this-foo)
          (wrap-json <opt>)
          (wrap-that-bar)))
    

    Above, the wrap-json wrapper is a combination of wrap-json-request and wrap-json-response. You can apply them both explicitly:

    (def app
      (-> api-handler
          (wrap-this-foo)
          (wrap-json-request <opt>)
          (wrap-json-response <opt>)
          (wrap-that-bar)))
    

    All the three wrap-json... middleware accept a handler to wrap and a map of options. Here is the options supported:

    Name Direction Description
    :object-mapper request, response An custom instance of ObjectMapper
    :slot request A field to assoc the parsed JSON data (1)
    :malformed-response request A ring response returned when payload cannot be parsed (2)

    Notes:

    1. The default slot name is :json. Please avoid using :body or :params to prevent overriding existing request fields. This is especially important for :body! Often, you need the origin input stream to calculate an MD5 or SHA-256 hash-sum of the payload. If you overwrite the :body field, you cannot do that.

    2. The default malformed response is something like 400 “Malformed JSON” (plain text).

    A full example:

    (def json-opt
      {:slot :data
       :object-mapper tagged-mapper ;; see above
       :malformed-response {:status 404
                            :body "<h1>Bad JSON</h1>"
                            :headers {"content-type" "text/html"}}})
    
    (def app
      (-> api-handler
          (wrap-this-foo)
          (wrap-json json-opt)
          (wrap-that-bar)))
    
  • PG2 release 0.1.5: Migrations

    PG2 version 0.1.5 ships its own migration engine through the pg2-migration package. Like Migratus or Ragtime, it allows to grow the database schema continuously, track changes and apply them with care.

    Table of Content

    Installation

    ;; lein
    [com.github.igrishaev/pg2-migration "0.1.5]
    
    ;; deps
    com.github.igrishaev/pg2-migration {:mvn/version "0.1.5"}
    

    Concepts

    Migrations are SQL files that are applied to the database in certain order. A migration has an id and a direction: next/up or prev/down. Usually it’s split on two files called <id>.up.sql and <id>.down.sql holding SQL commands. Say, the -up file creates a table with an index, and the -down one drops the index first, and then the table.

    Migrations might have a slug: a short human-friendly text describing changes. For example, in a file called 002.create-users-table.up.sql, the slug is “Create users table”.

    Naming

    In PG2, the migration framework looks for files matching the following pattern:

    <id>.<slug>.<direction>.sql
    

    where:

    • id is a Long number, for example 12345 (a counter), or 20240311 (date precision), or 20240311235959 (date & time precision);

    • slug is an optional word or group of words joined with - or _, for example create-users-table-and-index or remove_some_view. When rendered, both - and _ are replaced with spaces, and the phrase is capitalized.

    • direction is either prev/down or next/up. Internally, down and up are transformed to prev and next because these two have the same amount of characters and files look better.

    Examples:

    • 001-create-users.next.sql
    • 012-next-only-migration.up.sql
    • 153-add-some-table.next.sql

    Above, the leading zeroes in ids are used for better alignment only. Infernally they are transferred into 1, 12 and 153 Long numbers. Thus, 001, 01 and 1 become the same id 1 after parsing.

    Each id has at most two directions: prev/down and next/up. On bootstrap, the engine checks it to prevent weird behaviour. The table below shows there are two rows which, after parsing, have the same (id, direction) pair. The bootstrap step will end up with an exception saying which files duplicate each other.

    Filename Parsed
    001-some-trivial-slug.next.sql (1, next)
    001-some-simple-slug.next.sql (1, next)

    A migration might have only one direction, e.g. next/up or prev/down file only.

    When parsing, the registry is ignored meaning that both 001-Create-Users.NEXT.sql and 001-CREATE-USERS.next.SQL files produce the same map.

    SQL

    The files hold SQL expressions to be evaluated by the engine. Here is the content of the 001-create-users.next.sql file:

    create table IF NOT EXISTS test_users (
      id serial primary key,
      name text not null
    );
    
    BEGIN;
    
    insert into test_users (name) values ('Ivan');
    insert into test_users (name) values ('Huan');
    insert into test_users (name) values ('Juan');
    
    COMMIT;
    

    Pay attention to the following points.

    • A single file might have as many SQL expressions as you want. There is no need to separate them with magic comments like --;; as Migratus requires. The whole file is executed in a single query. Use the standard semicolon at the end of each expression.

    • There is no a hidden transaction management. Transactions are up to you: they are explicit! Above, we wrap tree INSERT queries into a single transaction. You can use save-points, rollbacks, or whatever you want. Note that not all expressions can be in a transaction. Say, the CREATE TABLE one cannot and thus is out from the transaction scope.

    For granular transaction control, split your complex changes on two or three files named like this:

    # direct parts
    001-huge-update-step-1.next.sql
    002-huge-update-step-2.next.sql
    003-huge-update-step-3.next.sql
    
    # backward counterparts
    003-huge-update-step-3.prev.sql
    002-huge-update-step-2.prev.sql
    001-huge-update-step-1.prev.sql
    

    No Code-Driven Migrations

    At the moment, neither .edn nor .clj migrations are supported. This is by design because personally I’m highly against mixing SQL and Clojure. Every time I see an EDN transaction, I get angry. Mixing these two for database management is the worst idea one can come up with. If you’re thinking about migrating a database with Clojure, please close you laptop and have a walk to the nearest park.

    Migration Resources

    Migration files are stored in project resources. The default search path is migrations. Thus, their physical location is resources/migrations. The engine scans the migrations resource for children files. Files from nested directories are also taken into account. The engine supports Jar resources when running the code from an uberjar.

    The resource path can be overridden with settings.

    Migration Table

    All the applied migrations are tracked in a database table called migrations by default. The engine saves the id and the slug or a migration applied as well as the current timestamp of the event. The timestamp field has a time zone. Here is the structure of the table:

    CREATE TABLE IF NOT EXISTS migrations (
      id BIGINT PRIMARY KEY,
      slug TEXT,
      created_at timestamp with time zone not null default current_timestamp
    )
    

    Every time you apply a migration, a new record is inserted into the table. On rollback, a corresponding migration is deleted.

    You can override the name of the table in settings (see below).

    CLI Interface

    The migration engine is controlled with both API and CLI interface. Let’s review CLI first.

    The pg.migration.cli namespaces acts like the main entry point. It accepts general options, a command, and command-specific options:

    <global options> <command> <command options>
    

    General options are:

    -c, --config CONNFIG     migration.config.edn      Path to the .edn config file
    -p, --port PORT          5432                      Port number
    -h, --host HOST          localhost                 Host name
    -u, --user USER          The current USER env var  User
    -w, --password PASSWORD  <empty string>            Password
    -d, --database DATABASE  The current USER env var  Database
        --table TABLE        :migrations               Migrations table
        --path PATH          migrations                Migrations path
    

    Most of the options have default values. Both user and database names come from the USER environment variable. The password is an empty string by default. For local trusted connections, the password might not be required.

    The list of the commands:

    Name Meaning
    create Create a pair of blank up & down migration files
    help Print a help message
    list Show all the migrations and their status (applied or not)
    migrate Migrate forward (everything, next only, or up to a certain ID)
    rollback Rollback (the current one, everything, or down to a certain ID)

    Each command has its own sub-options which we will describe below.

    Here is how you review the migrations:

    <lein or deps preamble> \
        -h 127.0.0.1 \
        -p 10150 \
        -u test \
        -w test \
        -d test \
        --table migrations_test \
        --path migrations \
        list
    
    |    ID | Applied? | Slug
    | ----- | -------- | --------
    |     1 | true     | create users
    |     2 | false    | create profiles
    |     3 | false    | next only migration
    |     4 | false    | prev only migration
    |     5 | false    | add some table
    

    Every command has its own arguments and help message. For example, to review the create command, run:

    lein with-profile +migrations run -m pg.migration.cli -c config.example.edn create --help
    
    Syntax:
          --id ID             The id of the migration (auto-generated if not set)
          --slug SLUG         Optional slug (e.g. 'create-users-table')
          --help       false  Show help message
    

    Config

    Passing -u, -h, and other arguments all the time is inconvenient. The engine can read them at once from a config file. The default config location is migration.config.edn. Override the path to the config using the -c parameter:

    <lein/deps> -c config.edn list
    

    The config file has the following structure:

    {:host "127.0.0.1"
     :port 10150
     :user "test"
     :password #env PG_PASSWORD
     :database "test"
     :migrations-table :migrations_test
     :migrations-path "migrations"}
    

    The :migrations-table field must be a keyword because it takes place in a HoneySQL map.

    The :migrations-path field is a string referencing a resource with migrations.

    Pay attention to the #env tag. The engine uses custom readers when loading a config. The tag reads the actual value from an environment variable. Thus, the database password won’t be exposed to everyone. When the variable is not set, an exception is thrown.

    Commands

    Create

    The create command makes a pair of two blank migration files. If not set, the id is generated automatically using the YYYYmmddHHMMSS pattern.

    lein with-profile +migration run -m pg.migration.cli \
      -c config.example.edn \
      create
    
    ls -l migrations
    
    20240312074156.next.sql
    20240312074156.prev.sql
    

    You can also provide a custom id and a slug as well:

    lein with-profile +migration run -m pg.migration.cli \
      -c config.example.edn \
      create \
      --id 100500 \
      --slug 'some huge changes in tables'
    
    ll migrations
    
    100500.some-huge-changes-in-tables.next.sql
    100500.some-huge-changes-in-tables.prev.sql
    20240312074156.next.sql
    20240312074156.prev.sql
    

    List

    The list command renders all the migrations and their status: whether they are applied or not.

    lein with-profile +migration run -m pg.migration.cli -c config.example.edn list
    
    |    ID | Applied? | Slug
    | ----- | -------- | --------
    |     1 | true     | create users
    |     2 | true     | create profiles
    |     3 | true     | next only migration
    |     4 | false    | prev only migration
    |     5 | false    | add some table
    

    Migrate

    The migrate command applies migrations to the database. By default, all the pending migrations are processed. You can change this behaviour using these flags:

    ... migrate --help
    
    Syntax:
          --all           Migrate all the pending migrations
          --one           Migrate next a single pending migration
          --to ID         Migrate next to certain migration
          --help   false  Show help message
    

    With the --one flag set, only one next migration will be applied. If --to parameter is set, only migrations up to this given ID are processed. Examples:

    ... migrate           # all migrations
    ... migrate --all     # all migrations
    ... migrate --one     # next only
    ... migrate --to 123  # all that <= 123
    

    Rollback

    The rollback command reverses changes in the database and removes corresponding records from the migration table. By default, only the current migration is rolled back. Syntax:

    ... rollback --help
    
    Syntax:
          --all           Rollback all the previous migrations
          --one           Rollback to the previous migration
          --to ID         Rollback to certain migration
          --help   false  Show help message
    

    The --one argument is the default behaviour. When --all is passed, all the backward migrations are processed. To rollback to a certain migration, pass --to ID. Examples:

    ... rollback               # current only
    ... rollback --one         # current only
    ... rollback --to 20240515 # down to 20240515
    ... rollback --all         # down to the very beginning
    

    Lein examples

    Lein preamble looks usually something like this:

    > lein run -m pg.migration.cli <ARGS>
    

    The pg2-migration library must be in dependencies. Since migrations are managed aside from the main application, they’re put into a separate profile, for example:

    :profiles
    {:migrations
     {:main pg.migration.cli
      :resource-paths ["path/to/resources"]
      :dependencies
      [[com.github.igrishaev/pg2-core ...]]}}
    

    Above, the migrations profile has the dependency and the :main attribute. Now run lein run with migration arguments:

    > lein with-profile +migrations run -c migration.config.edn migrate --to 100500
    

    Deps.edn examples

    Here is an example of an alias in deps.edn that prints pending migrations:

    {:aliases
     {:migrations-list
      {:extra-deps
       {com.github.igrishaev/pg2-migration {:mvn/version "..."}}
       :extra-paths
       ["test/resources"]
       :main-opts
       ["-m" "pg.migration.cli"
        "-h" "127.0.0.1"
        "-p" "10150"
        "-u" "test"
        "-w" "test"
        "-d" "test"
        "--table" "migrations_test"
        "--path" "migrations"
        "list"]}}}
    

    Run it as follows:

    > clj -M:migrations-list
    

    You can shorten it by using the config file. Move all the parameters into the migration.config.edn file, and keep only a command with its sub-arguments in the :main-opts vector:

    {:aliases
     {:migrations-migrate
      {:extra-deps
       {com.github.igrishaev/pg2-migration {:mvn/version "..."}}
       :extra-paths
       ["test/resources"]
       :main-opts ["migrate" "--all"]}}}
    

    To migrate:

    > clj -M:migrations-migrate
    

    API Interface

    There is a way to manage migrations through code. The pg.migration.core namespace provides basic functions to list, create, migrate, and rollback migrations.

    To migrate, call one of the following functions: migrate-to, migrate-all, and migrate-one. All of them accept a config map:

    (ns demo
      (:require
       [pg.migration.core :as mig]))
    
    (def CONFIG
      {:host "127.0.0.1"
       :port 5432
       :user "test"
       :password "secret"
       :database "test"
       :migrations-table :test_migrations
       :migrations-path "migrations"})
    
    ;; migrate all pinding migrations
    (mig/migrate-all CONFIG)
    
    ;; migrate only one next migration
    (mig/migrate-one CONFIG)
    
    ;; migrate to a certain migration
    (mig/migrate-to CONFIG 20240313)
    

    The same applies to rollback:

    ;; rollback all previously applied migrations
    (mig/rollback-all CONFIG)
    
    ;; rollback the current migration
    (mig/migrate-one CONFIG)
    
    ;; rollback to the given migration
    (mig/rollback-to CONFIG 20230228)
    

    The read-disk-migrations function reads migrations from disk. It returns a sorted map without information about whether migrations have been applied:

    (mig/read-disk-migrations "migrations")
    
    {1
     {:id 1
      :slug "create users"
      :url-prev #object[java.net.URL "file:/.../migrations/001-create-users.prev.sql"]
      :url-next #object[java.net.URL "file:/.../migrations/001-create-users.next.sql"]}
     2
     {:id 2
      :slug "create profiles"
      :url-prev #object[java.net.URL "file:/.../migrations/foobar/002-create-profiles.prev.sql"]
      :url-next #object[java.net.URL "file:/.../migrations/foobar/002-create-profiles.next.sql"]}
     ...}
    

    The make-scope function accepts a config map and returns a scope map. The scope map knows everything about the state of migrations, namely: which of them have been applied, what is the current migration, the table name, the resource path, and more.

    The function create-migration-files creates and returns a pair of empty SQL files. By default, the id is generated from the current date & time, and the slug is missing:

    (create-migration-files "migrations")
    
    [#object[java.io.File "migrations/20240313120122.prev.sql"]
     #object[java.io.File "migrations/20240313120122.next.sql"]]
    

    Pass id and slug in options if needed:

    (create-migration-files "migrations" {:id 12345 :slug "Hello migration"})
    
    [#object[java.io.File "migrations/12345.hello-migration.prev.sql"]
     #object[java.io.File "migrations/12345.hello-migration.next.sql"]]
    

    Conflicts

    On bootstrap, the engine checks migrations for conflicts. A conflict is a situation when a migration with less id has been applied before a migration with greater id. Usually it happens when two developers create migrations in parallel and merge them in a wrong order. For example:

    • the latest migration id is 20240312;
    • developer A makes a new branch and creates a migration 20240315;
    • the next day, developer B opens a new branch with a migration 20240316;
    • dev B merges the branch, now we have 20240312, then 20240316;
    • dev A merges the branch, and we have 20240312, 20240316, 20240315.

    When you try to apply migration 20240315, the engine will check if 20240316 has already been applied. If yes, an exception pops up saying which migration cause the problem (in our case, these are 20240316 and 20240315). To recover from the conflict, rename 20240315 to 20240317.

    In other words: this is a conflict:

    id        applied?
    20240312  true
    20240315  false
    20240316  true  ;; applied before 20240315
    

    And this is a solution:

    id        applied?
    20240312  true
    20240316  true
    20240317  false ;; 20240315 renamed to 20240317
    
  • Вдруг изменится

    Иногда мы видим замечания коллег: вдруг в будущем эта штука изменится? Давай исправим сейчас, чтобы не возиться потом.

    Благие намерения, однако: коллеге кажется, а переделывать вам. Он написал комментарий, а вам переписывать экран кода.

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

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

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

    Наконец, за умной гипотезой может быть банальное говноедство — емкое слово, чтобы описать ситуацию (и человека). Но даже в этом случае мы, специалисты, должны хранить лицо и отвечать со своего уровня.

    Звучит хорошо, но как бы этому следовать!

  • Правда

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

    Если собеседник обижается на правду, стоит подумать, как лучше ее донести и вообще — стоит ли ее доносить.

  • Дело раскрыто

    Однажды я писал о том, что Слак отказался работать с интересной формулировкой: устарела операционная система. Я обновил приложение, но не помогло. Очевидно, Слак захотел доступ к какой-то функции, которой нет в текущей операционке. Я решил, что эта функция нужна маркетингу или “более удобным” обновлениям.

    Так и вышло!

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

    Приложи пальчик, а дальше я сама.

    Эта модалка действует на всю систему. Она вылазит не в Слаке, а на уровне операционки. Если бы пишете код, шарите экран, смотрите ютучик — будте готовы, что в любой момент она вылезет и потребует реакции.

    На минуточку: она загораживает все! Вот такой прикол. Через час после закрытия Слак проделает то же самое, и модалка вылезет опять.

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

    Но есть способ. Помните я писал про фаервол Lulu? С его помощью я отбираю у программ доступ в сеть, в том числе — к обновлениям. Оказалось, нужно добавить запрет как на картинке, и готово — доступа к обновлениям нет, модалка не появляется.

    Все довольны, все смеются. Но блядским Слаком все равно стараюсь пользоваться реже.

  • Сервисы

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

    Ощущение, что сервисы делают люди, лишенные эмпатии. Например, есть метод get-by-id, чтобы взять сущность по айдишке. Угадайте, сколько сервисов поддерживают get-by-idS — получить список по айдишкам? В лучшем случае половина. У оставшейся половины get-by-ids возвращает сущности порциями по сто. Тебе нужно 250? Пиши обертку с пагинацией, next-page, limit/offset, вот это все.

    Вызывал сервис несколько тысяч раз? Начинаются разборки “кто нас дидосит”. Вызывай редко, не больше десяти раз, а еще лучше никогда. И везде проблемы с сообщением об ошибке. Передал что-то не то — иди смотри логи.

    Я согласен с тем, что логику нужно разносить по сервисам. Но сервисы должны использовать общую шину данных: базу, очередь сообщений, файлы в S3 в конце концов. Гонять друг другу JSON выглядит хорошо в теории, но на практике — фу.

    Условный Постгрес выплюнет миллион записей за доли секунды. Забрать этот же миллион из другого сервиса — приключение на неделю. Тут и метрики, лимиты, квоты, сетевые спайки, etc… А когда таких запросов несколько, сервис ложится спать.

    Эти сервисы напоминают современного айтишника: хрупкого, тряпковатого мужчину 25 лет, который чуть что — выгорает. У которого все токсичны и вообще — отстаньте.

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

    А мне разгребать.

  • Карго-культ (2)

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

    Впервые эксперимент упоминется в книге Competing for the Future by Gary Hamel and C. K. Prahalad. Вот дословная цитата:

    4 monkeys in a room. In the center of the room is a tall pole with a bunch of bananas suspended from the top. One of the four monkeys scampers up the pole and grabs the bananas. Just as he does, he is hit with a torrent of cold water from an overhead shower. He runs like hell back down the pole without the bananas. Eventually, the other three try it with the same outcome. Finally, they just sit and don’t even try again. To hell with the damn bananas. But then, they remove one of the four monkeys and replace him with a new one. The new monkey enters the room, spots the bananas and decides to go for it. Just as he is about to scamper up the pole, the other three reach out and drag him back down. After a while, he gets the message. There is something wrong, bad or evil that happens if you go after those bananas. So, they kept replacing an existing monkey with a new one and each time, none of the new monkeys ever made it to the top. They each got the same message. Don’t climb that pole. None of them knew exactly why they shouldn’t climb the pole, they just knew not to. They all respected the well established precedent. EVEN AFTER THE SHOWER WAS REMOVED!

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

    Our apologies, but Professor Hamel does not have the original source information at hand in terms of your request.

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

    Напоминает историю про 25 кадр, который, в отличие от обезьян, был полностью развенчан.

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

    См. обсуждение на Stack Exchange.

  • Карго-культ (1)

    Возможно, не все знают, как работает карго-культ у обезьян. А это очень интересно.

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

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

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

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

    Сказанное не значит, что нужно быть бунтарем и расшатывать основы. Здоровый баланс между крайностями — вот что нужно соблюдать.

  • Лук

    Что ж, таки подъехал новый лук от Гугла. И не соврали: современный и прочувственный, что бы это ни значило.

    Главное, теперь мы знаем: форму даже с одним импутом можно растянуть на 80 процентов площади. Дизайнерам воздушных интерфейсов взять на заметку.

Страница 2 из 74