Voltar ao blog
    Artigo de convidado
    Chris Harrison
    24 de agosto de 2026
    10 min de leitura

    A reescrita com Arrow Scan: como o GizmoSQL v1.37.0 tornou a ingestão em massa 7 vezes mais rápida

    O GizmoSQL v1.37.0 deixou de converter manualmente cada célula Arrow ao entrar no DuckDB e passou a transmitir o lote inteiro pelo arrow_scan do próprio DuckDB: cargas em massa 7,2 vezes mais rápidas, decimais exatos e colunas de geometria que finalmente chegam como GEOMETRY. Veja a análise do commit e um repositório executável para reproduzir os resultados.

    GizmoSQL
    Ingestão em massa
    Arrow Flight SQL
    DuckDB
    ADBC
    Desempenho
    Apache Arrow
    GizmoSQL v1.37.0: ingestão em massa refeita com arrow_scan do DuckDB, 7,2 vezes mais rápida

    Artigo de convidado: este artigo foi escrito por Chris Harrison e é publicado aqui com a autorização do autor. O benchmark executável que reproduz todos os números deste artigo está no GitHub.

    No início da minha carreira, ficar de plantão era o pior. A conexão era lenta: um modem, uma linha telefônica e muito café. Eu acessava remotamente para consertar o que estivesse quebrado, e a conexão em si já era metade do trabalho: discar, esperar a negociação, esperar de novo até algo útil aparecer no monitor.

    Então veio a banda larga, e a mudança foi além da velocidade. Tarefas que antes justificavam sair da mesa ficaram tão rápidas que você deixou de perceber que estavam acontecendo. Isso mudou minha forma de trabalhar.

    Ao medir o novo caminho de ingestão em massa do GizmoSQL, essa lembrança voltou várias vezes. Veja o que a v1.37.0 faz ao carregar a lineitem do TPC-H com fator de escala 2: de 36 segundos para 5. Tempo suficiente para desviar o olhar, verificar outra coisa e voltar — contra simplesmente… pronto. Não é só um número maior no gráfico de benchmark. É a diferença entre esperar pelo banco e não esperar.

    Resumo da versão

    Versão1.37.0
    Lançamento2026-08-24
    Commitb264f7c
    AutorPhilip Moore
    Alterações8 arquivos, +418 / −424
    36,0 s → 5,0 s para carregar a lineitem do TPC-H SF2 (11.997.996 linhas, 16 colunas, lotes de 50.000 linhas, Python ADBC sobre gRPC sem criptografia em localhost, banco novo, mode="create"). Mesma contagem de linhas e mesmo checksum de sum(l_quantity) nas duas execuções. Tempo decorrido 7,2× menor; de 0,33 para 2,39 milhões de linhas/s.

    O que o DoPut fazia antes

    Toda chamada ADBC adbc_ingest() e toda carga em massa Flight SQL chegam ao mesmo ponto no servidor: DoPut(CommandStatementIngest). Até a v1.36, esse manipulador fazia tudo pelo caminho difícil. Para cada RecordBatch Arrow recebido, percorria cada linha e coluna, passava o tipo Arrow da célula por um switch de dezessete casos, construía manualmente um duckdb::Value e o enviava a um duckdb::Appender. Cada célula passava por sua própria pequena negociação: identificar o tipo e transferir o valor, um de cada vez.

    Esse switch precisava reimplementar conversões que o próprio DuckDB já tinha: cálculos de época para DATE32/DATE64, ajustes conforme a unidade para TIME32/TIME64/TIMESTAMP e processamento recursivo de LIST, STRUCT e MAP. Os decimais não se saíam bem:

    // -------- DECIMAL128 -> DuckDB DECIMAL --------
    case arrow::Type::DECIMAL128: {
      auto typed = std::static_pointer_cast<arrow::Decimal128Array>(arr);
      std::string s = typed->FormatValue(row);
      // Parse to double – yes, this loses exactness but is simple + robust
      double d = std::stod(s);
      return duckdb::Value::DOUBLE(d);
    }

    Uma coluna financeira DECIMAL(18,2) entrava exata e saía como um double arredondado. O modo append também tinha sua adaptação: como o Appender ignora o planejador de consultas, não havia como usar o preenchimento de padrões de BY NAME nas colunas não enviadas pelo cliente. O servidor criava uma tabela temporária <table>_interim_bulk_ingest_temp_<session>, inseria nela e depois executava INSERT INTO target BY NAME SELECT * FROM interim e a excluía. Eram uma segunda tabela e uma segunda passagem apenas para acertar os valores padrão, e só em um dos três modos de ingestão.

    Esse código não estava exatamente errado: era específico demais. Cada tipo Arrow que o leitor Arrow do próprio DuckDB já entendia precisava ser ensinado novamente a esse caminho e mantido em sincronia manualmente conforme Arrow e DuckDB evoluíam.

    O que a v1.37.0 faz: para de converter célula a célula e passa a usar o scanner

    Se o caminho antigo era uma conexão discada — negociar e transferir cada célula lentamente — este é a banda larga: negociar uma vez, registrar o fluxo inteiro e deixar a outra ponta consumir na velocidade que puder. O DuckDB já tem um importador Arrow vetorizado e amplamente usado: arrow_scan, a função de tabela por trás de cada chamada duckdb.from_arrow() e leitura de arquivo Arrow. A solução da v1.37.0 é parar de competir com ele e começar a usá-lo. O fluxo Flight recebido é encapsulado uma vez em um arrow::RecordBatchReader real, exportado pela interface Arrow C stream e registrado como uma view temporária da sessão. Carregar um lote vira uma instrução, em todos os modos:

    ArrowIngestStream ingest_stream(reader, arrow_schema);
    const std::string ingest_view =
        "__gizmosql_ingest_" + strip_dashes(client_session->session_id);
    ARROW_RETURN_NOT_OK(ingest_stream.RegisterView(conn, ingest_view));
    
    const std::string insert_sql =
        "INSERT INTO " + target_table +
        " BY NAME SELECT * FROM " + QuoteIdent(ingest_view);
    ARROW_ASSIGN_OR_RAISE(auto insert_res,
                           RunAndLogQueryWithResult(client_session, insert_sql));
    total_rows = insert_res->GetValue(0, 0).GetValue<int64_t>();
    
    if (total_rows != ingest_stream.total_rows())
      return Status::Invalid("row count mismatch: inserted vs. streamed");

    BY NAME agora preenche os valores padrão das colunas em todos os modos, não apenas append. A tabela intermediária desapareceu, junto com a segunda passagem que exigia. Tipos aninhados, decimais, timestamps com fuso e codificações por dicionário passam pelo mesmo caminho vetorizado que o DuckDB usa em todo o sistema, sem uma implementação paralela que precisasse acompanhá-lo. E como a contagem de linhas retornada pelo INSERT é conferida com o que FlightIngestBatchReader contou na rede, uma carga parcial silenciosa é detectada imediatamente.

    A view é registrada sobre um objeto de fluxo que vive na pilha de chamadas e não pode sobreviver à solicitação. Uma pequena proteção RAII a remove em qualquer saída, seja por sucesso ou exceção:

    struct ViewDropper {
      duckdb::Connection& conn;
      std::string name;
      ~ViewDropper() {
        try { conn.Query("DROP VIEW IF EXISTS " + QuoteIdent(name)); }
        catch (...) {}
      }
    } view_dropper{conn, ingest_view};

    Mesma origem, mesmo destino — quatro etapas em vez de seis

    BEFORE · &le;v1.36.1                          AFTER · v1.37.0
    ────────────────────────────           ────────────────────────────
    Flight DoPut stream (batches)          Flight DoPut stream (batches)
            │ reader->Next(), per batch            │ wrapped once as RecordBatchReader
            ▼                                       ▼
    chunk loop, one batch at a time        ArrowIngestStream
            │ per row × per column                  │ (FlightIngestBatchReader)
            ▼                                        │ conn.TableFunction("arrow_scan")
    ConvertArrowCellToDuckDBValue()                  ▼
      17-case type switch, hand-rolled       session-temp VIEW
      decimal / date / time math             __gizmosql_ingest_<session>
            │ Appender.Append(), per cell            │ one query, every mode
            ▼                                        ▼
    duckdb::Appender                        INSERT INTO target BY NAME
      ├─ create/replace: straight in          SELECT * FROM view
      └─ append: scratch *_interim_temp               │ verify: rows inserted ==
           │ 2nd INSERT ... BY NAME, DROP             │ rows streamed by client
            ▼                                        ▼
    target table                            target table

    Colunas geoarrow.* são materializadas como GEOMETRY no mesmo ponto em que os demais tipos são convertidos. Mais detalhes abaixo.

    Colunas de geometria finalmente chegam como GEOMETRY

    O GeoPandas — e qualquer ferramenta que use GeoArrow — marca o campo Arrow de uma coluna geométrica com uma chave de metadados ARROW:extension:name, como geoarrow.wkb. É exatamente o que as próprias colunas GEOMETRY do GizmoSQL emitem na saída. Na entrada, até a v1.36, nada examinava esses metadados: GetDuckDBTypeFromArrowType() via apenas um array Arrow binary. Assim, uma tabela nova recebia uma coluna BLOB, e acrescentar geometria a uma coluna GEOMETRY existente falhava na conversão de blob para geometry, que não podia ser realizada.

    A correção é uma verificação de metadados de quatro linhas, aplicada na criação da tabela e respeitada automaticamente quando a ingestão passa por arrow_scan, que já sabe materializar tipos de extensão GeoArrow como GEOMETRY quando a extensão espacial está carregada:

    bool IsGeoArrowField(const arrow::Field& field) {
      const auto& metadata = field.metadata();
      if (!metadata) return false;
      const int idx = metadata->FindKey("ARROW:extension:name");
      return idx >= 0 && metadata->value(idx).rfind("geoarrow.", 0) == 0;
    }

    Como a verificação fica no servidor, corrige o comportamento de todos os clientes — JDBC, Go, C++, Python ADBC e a CLI dbc — e não apenas a adaptação pontual no cliente lançada em adbc-driver-gizmosql 2.0.1, que esta versão torna redundante. Um novo teste de regressão (tests/integration/test_geoarrow.cpp) ingere pontos WKB em uma tabela nova (create), acrescenta outros à mesma coluna GEOMETRY (append) e substitui a tabela inteira (replace), verificando typeof(geom) = 'GEOMETRY' e fazendo cada lote passar por ST_AsText para confirmar que as coordenadas foram preservadas.

    O gargalo mudou: o limite de mensagens do gRPC

    Como a conversão no servidor deixou de ser a parte lenta, a decodificação de Parquet no cliente e a transferência gRPC passam a dominar. Isso evidencia um limite que sempre existiu, mas raramente era atingido: cada lote Arrow viaja como uma mensagem Flight, e o limite padrão do gRPC é de 16 MB. Até uma conexão de banda larga tem um tamanho máximo de quadro — este é o do GizmoSQL. Um lote de 1.000.000 de linhas da lineitem do TPC-H ocupa cerca de 170 MB e é rejeitado: trying to send message larger than max.

    1with conn.cursor() as cur:
    2    cur.adbc_ingest(
    3        table_name="lineitem",
    4        data=record_batch_reader,   # 50,000-row batches: comfortably
    5                                     # under the 16 MB gRPC message cap
    6        mode="create",
    7    )

    docs/bulk_ingestion.md agora explica isso diretamente: 50.000 a 100.000 linhas por lote é um padrão seguro para tabelas típicas. Aumente se suas linhas forem especialmente estreitas; reduza se forem especialmente largas.

    Em números: o que mudou no diff

    O saldo de linhas quase não muda: seis linhas a menos no total. O que mudou foi onde a complexidade fica: duckdb_server.cpp perdeu todo o switch manual de conversão e a lógica da tabela intermediária, substituídos por uma chamada a dois arquivos pequenos, cada um com uma única finalidade.

    Arquivo + − O que mudou
    duckdb_server.cpp+42−423remoção do switch de conversão de tipos e do caminho da tabela intermediária
    duckdb_arrow_ingest.cpp+125−0novo: a ponte com arrow_scan
    duckdb_arrow_ingest.h+93−0novo: sua interface pública
    test_geoarrow.cpp+91−0testes de regressão para create / append / replace
    docs/bulk_ingestion.md+27−0orientação de tamanho no gRPC e funcionamento da nova carga
    CHANGELOG.md+30−0—
    docs/geometry.md+8−0nota sobre GEOMETRY na ingestão
    CMakeLists.txt+2−1inclusão do novo arquivo-fonte
    Total · 8 arquivos+418−424saldo de −6 linhas

    O código não encolheu porque alguém encontrou um canal maior. Ele encolheu porque o servidor parou de construir seu próprio modem.

    Comprove você mesmo

    Números em uma página ainda são só uma história com um gráfico. Por isso, este artigo acompanha um pequeno projeto real, bulk-ingest-demo, que executa o driver apresentado aqui contra dois servidores GizmoSQL ativos ao mesmo tempo: v1.36.1, a última versão antes da reescrita, e a atual, cada um em sua própria porta.

    # 1. start both servers, side by side
    docker run --name gizmosql-v1361 --detach --rm --publish 31338:31337 \
        --env TLS_ENABLED=1 --env GIZMOSQL_PASSWORD=gizmosql_password \
        gizmodata/gizmosql:v1.36.1                    # before, port 31338
    
    docker run --name gizmosql --detach --rm --publish 31337:31337 \
        --env TLS_ENABLED=1 --env GIZMOSQL_PASSWORD=gizmosql_password \
        gizmodata/gizmosql:latest                     # after,  port 31337
    
    # 2. install the driver this article is actually about
    pip install -r requirements.txt
    
    # 3. the article's actual benchmark shape, against both
    python bench_lineitem.py --scale-factor 1 --port 31338   # before
    python bench_lineitem.py --scale-factor 1 --port 31337   # after
    
    # 4. see the gRPC cap for yourself: force one giant batch
    python bench_ingest.py --rows 2000000 --batch-size 0 --port 31337
    #    -> trying to send message larger than max (16 MB gRPC cap)

    Executado agora, nesta máquina, com a lineitem do TPC-H de fator de escala 1 (6.001.215 linhas): v1.36.1 carregou os dados em 9,0 s; o servidor atual, em 1,1 s — 8,2× mais rápido, com checksum de sum(l_quantity) idêntico nos dois. Não é um número copiado de um slide: veio de uma execução real, que é justamente o objetivo.

    Uma ressalva descoberta na execução: teste uma tabela mais estreita do mesmo modo, nas duas portas, e a diferença quase desaparece. Com 5.000.000 de linhas, os dois servidores ficam dentro da margem de variação. O custo do caminho antigo cresce com o número de colunas e as conversões decimais, e lineitem tem muitos dos dois. O resultado principal vale para o perfil de lineitem, não para qualquer tabela.

    Notas da versão v1.37.0 — 24/08/2026

    • Alterado: a ingestão em massa (Flight SQL DoPut / ADBC adbc_ingest) agora é transmitida pelo scanner Arrow do DuckDB: um único INSERT INTO target BY NAME SELECT * FROM view sobre um arrow_scan registrado substitui a conversão duckdb::Value por célula e a tabela intermediária do modo append.
    • Corrigido: colunas de geometria agora são ingeridas em massa como GEOMETRY, em vez de BLOB, no servidor (adbc-driver-gizmosql#5), para todos os clientes, nos modos create, append e replace.

    Trinta anos depois, a mesma lição: uma conexão que deixa de atrapalhar muda sua forma de trabalhar.


    O projeto de benchmark deste artigo — bench_lineitem.py, bench_ingest.py e instruções para executar as duas versões do GizmoSQL lado a lado — está no GitHub.

    GizmoSQL: github.com/gizmodata/gizmosql · commit b264f7c

    Pronto para experimentar o GizmoSQL?

    Experimente análises de dados ultrarrápidas com nosso mecanismo SQL de código aberto

    Agende uma demonstração!Agendamento via Calendly