[О блоге] [наверх] [пред] [2023-02-21 13:36:31+03:00] [ac7a2a8173b2f4685d98d94f9d0e7579c23ecf28]
Темы: [hate]

Уровень курсов/практикума по Python

Один знакомый just-for-fun решил поучаствовать в одном курсе по Python.
Дали задание: написать скрипт по переливке данных из нескольких таблиц
из SQLite3 БД в таблицы в PostgreSQL. Названия полей не совпадают, но
количество и суть остаётся прежней. Типа должно быть так, как условие:

    data = load_from_sqlite3(...)
    save_to_pgsql(data)
    # и нужно использовать dataclass для данных

Изначально задачу он решил просто загрузив все данные в виде списка
словариков. И я считаю что вполне себе разумное решение, когда никаких
условий по ресурсам не поставили. Решение отвергли, мол данных может
быть много. И у меня претензия: почему об этом не сказано в задании?
Программа которая рассчитана на перезаливку терабайт -- это одно, а
наколеночное one-time решение когда всё умещается в памяти -- это
другое. И оба варианта стоит уметь делать.

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

Как загружаются данные в эталонном варианте? Схематично как-то так,
опуская всякие мелочи типа инициализации подключения к БД, подключения
адаптеров/конвертеров для UUID/datetime типов данных, транзакции и прочее.
В чём-то наверное ошибся, пишу по памяти, но суть точно передам.

    def load_from_sqlite3():
        for klass, table in self.dataclasses2tables.items()
            yield [klass(**converter(obj)) for obj in load_table(table)]

    def load_table(table):
        self.conn.prepare_data = prepare_data
        self.conn.execute("SELECT * FROM %s" % table)
        while True:
            objs = self.conn.fetchmany(batch_size)
            if objs is None:
                break
            yield from objs

    def prepare_data(conn, row):
        data = {}
        for idx, name in conn.cols_description():
            data[name] = row[idx]

    def converter(obj):
        if "foo" in obj:
            obj["Foo"] = obj["foo"]
            del obj["foo"]
        if "bar_id" in obj:
            obj["BarId"] = obj["bar_id"]
            del obj["bar_id"]
        ...

Сохранение выглядело как-то так:

    def save_to_pgsql(data):
        for rows in data:
            table = klass2table[row[0].__class__]
            self.conn.executemany(
                "INSERT INTO %s ..." % (...),
                [astuple(row) for row in rows]
            )

"Наш" вариант:

    def load_from_sqlite3():
        for klass, (table, colnames) in self.tables2dataclasses.items()
            yield klass, load_table(klass, table, colnames)

    def load_table(table, klass):
        self.conn.prepare_data = lambda (conn, row): klass(*row)
        self.conn.execute("SELECT %s FROM %s" % (",".join(colnames), table))
        while True:
            objs = self.conn.fetchmany(batch_size)
            if objs is None:
                break
            yield from objs

    def save_to_pgsql(data):
        for klass, rows in data:
            table = klass2table[klass.__class__]
            stmt = "INSERT INTO %s ..." % (...)
            batch = []
            for row in rows:
                batch.append(astuple(row))
                if len(batch) == BatchSize:
                    self.conn.executemany(stmt, batch)
                    batch = []
            if len(batch) > 0:
                self.conn.executemany(stmt, batch)

Собственно, главная проблема "эталонного" кода: полная загрузка всех
данных каждой таблицы в память. Ибо и в load_from_sqlite3 и в
save_to_pgsql используются list comprehension-ы. Чем этот вариант
отличался бы от кода где не было бы генераторов вовсе? Который бы просто
разом загружал все данные в память списком и его передавал в save_to_pgsql?
Да ничем! Это полнейший fail. Мог ли рецензент ошибиться и не заметить
двойных квадратных скобок? Отнюдь, ведь они аж в двух местах присутствуют!
Это полностью нивелирует вообще всё что написано касательно генераторов
и обработкой пачками, раз всё равно всё загружается в память.

Отдельное безумие это сложность кода который конвертирует данные в
dataclass представление. Для *КАЖДОЙ* строки полученной из БД, он в
цикле идёт по описанию схемы таблицы чтобы переложить элементы кортежа в
именованные ключи словаря. А затем, переименовывает поля, внутри словаря.
А затем ещё и удаляет del-ом старое оставшееся название. Лютое безумие с
точки зрения процессора. Python -- ОЧЕНЬ медленный язык. Каждая строчка,
каждое действие, каждое обращение к методу это ощутимый overhead. Раз
речь про переливание данных, возможно миллиардов строчек, то миллиард
небольших операций превращается в воочию осязаемое время.

Например сделать
    d["x"] = d.pop("y")
быстрее чем
    d["x"] = d["y"]
    del d["y"]
Сделать полностью в памяти второй словарик и только заполнять его, не
модифицируя исходный -- ещё быстрее.

Но зачем это всё? Мы можем чётко сопоставить порядок полей таблицы БД и
порядок полей dataclass-а. Почему бы просто не делать:

    @dataclass
    class Foo:
        bar: int
        baz: int

    row = conn.execute("SELECT bar, baz ...")
    Foo(*row)

Никаких словарей, никаких преобразований, дорогих. Просто сопоставить
порядок полей, один раз в запрос передав вместо "*" чёткое условие
выборки. Знание о связи полей dataclass и полями таблицы что одной, что
другой БД -- всё равно зашивается в коде. В "нашем" случаев это просто
кортежи/словарики лежащие в klass2table.

И тут нет совсем уж хаков типа: appender = data.append ; appender(row)
Также как и все мы понимаем что если нам реально нужно ещё быстрее
скопировать данные, то тогда вообще стоит использовать COPY конструкцию,
вместо *many вызовов.

У нас удивляются что мало толковых ИТ-специалистов. А вот нечего, если
крупные курсы/практикумы считают неприемлемым более короткий,
существенно более быстрый, просасывающий любые объёмы данных, код.
Отвратительно. И я не просто так призвал опытного коллегу рассудить,
ведь, быть может, это я уже совершенно забыл Python? И ведь появится
тьма новоиспечённых Python-программистов, которые даже данные между
двумя БД не смогут перелить хоть сколько-то эффективно.

    [оставить комментарий]