fix(baseline): deduplica backfill 0068 por model_id

O bloco "backfill 0068 a partir de ai_models" do supabase/baseline.sql faz
`insert into public.ai_pricing (...)` a partir de um `select` de ai_models
embrulhado em `not exists`. ai_models é único por (provider, model_id), então
um mesmo model_id cadastrado sob dois provedores (ex.: openrouter e requesty)
gera DUAS linhas iguais DENTRO do mesmo select — o `not exists` não resolve,
porque os duplicados ainda não foram inseridos — e a PK `ai_pricing_pkey`
(só `model`) recusa com `duplicate key ... ai_pricing_pkey`. O update.sh para
na etapa "Atualizando o banco de dados" e o rollback `--force` falha no mesmo
ponto (issue #1998, instâncias v1.63.4→v1.66.1 e v1.64.1→v1.66.1).

Correção: `select distinct on (m.model_id) ... order by m.model_id,
m.input_price_per_million_cents asc`. Devolve UMA linha por model_id e escolhe
deterministicamente o provedor de MENOR preço quando há empate; o `not exists`
existente preserva a idempotência/re-aplicação. Não mexe em schema nem em
migration — é só o INSERT do apêndice do baseline que o self-host aplica.

Teste de invariante sobre o texto do baseline fica VERMELHO sem o distinct on
e verde com ele; roda na malha unitária (sem Postgres/Docker).
This commit is contained in:
webtecnica
2026-09-30 12:35:03 -03:00
parent 59c064d436
commit 4f7b896b7e
3 changed files with 114 additions and 2 deletions
@@ -0,0 +1,21 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O update.sh não falha mais com "duplicate key ... ai_pricing_pkey" quando um mesmo modelo está cadastrado em dois provedores
---
A atualização do aplicativo parava na etapa "Atualizando o banco de dados" com erro
`duplicate key value violates unique constraint "ai_pricing_pkey"` quando o mesmo
modelo existia ativo, com preço, em dois provedores ao mesmo tempo (ex.: openrouter
e requesty) e ainda não tinha linha em `ai_pricing`. Como `ai_models` permite um
`model_id` por provedor, o backfill de preços gerava duas linhas iguais dentro do
mesmo comando e a chave primária (que é só o `model`) recusava a segunda. O erro
não deixava nenhuma versão atualizar nem voltar com `--force` naquela instalação.
Agora o backfill emite uma única linha por modelo, escolhendo de forma determinística
o provedor de menor preço quando houver o mesmo modelo em mais de um provedor;
o mecanismo idempotente (não reescrever preço já existente) continua intacto. Quem
estava travado por esse erro basta rodar a atualização de novo: ela completa sem
ação manual no banco.
Contribuição de @webtecnica (#2008).
+12 -2
View File
@@ -7970,8 +7970,17 @@ $seed$;
-- não ai_pricing. Com a tabela vazia, computeCost() devolve 0 sem log e o teto
-- de ai_budgets nunca dispara. Derivado de ai_models: idempotente e
-- auto-curativo, cobre qualquer modelo futuro do catálogo.
--
-- `distinct on (m.model_id)`: ai_models é único por (provider, model_id), então
-- o MESMO model_id pode existir sob dois provedores (ex.: openrouter e requesty)
-- e, sem a deduplicação, o INSERT gerava DUAS linhas iguais dentro da mesma
-- passada e a PK `ai_pricing_pkey` (só `model`) recusava com
-- `duplicate key ... ai_pricing_pkey`. O `not exists` abaixo não resolve: os
-- duplicados estão dentro do MESMO select. `distinct on` devolve UMA linha por
-- model_id, e o `order by m.model_id, m.input_price_per_million_cents asc`
-- escolhe deterministicamente o provedor de MENOR preço quando houver empate.
insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes)
select
select distinct on (m.model_id)
m.model_id,
m.input_price_per_million_cents,
m.output_price_per_million_cents,
@@ -7983,7 +7992,8 @@ where m.deprecated_at is null
and not exists (
select 1 from public.ai_pricing p
where p.model = m.model_id and p.superseded_at is null
);
)
order by m.model_id, m.input_price_per_million_cents asc;
-- Embedding do RAG — não vive em ai_models.
insert into public.ai_pricing (model, embedding_cents_per_million_tokens, notes)
@@ -0,0 +1,81 @@
/**
* O BACKFILL 0068 NUNCA PODE GERAR DUAS LINHAS PARA O MESMO `model_id`.
*
* ## Por que este arquivo existe
*
* `ai_pricing` tem PK só em `model` (`ai_pricing_pkey`), enquanto `ai_models` é
* único por `(provider, model_id)`. O backfill 0068 deriva `ai_pricing` de
* `ai_models` num `insert ... select` idempotente embrulhado em `not exists`.
* Quando o MESMO `model_id` existe sob DOIS provedores (ex.: `openrouter` e
* `requesty`), o select devolve DUAS linhas iguais DENTRO da mesma passada — o
* `not exists` não enxerga, porque os duplicados ainda não foram inseridos — e a
* PK recusa com `duplicate key value violates unique constraint
* "ai_pricing_pkey"`. Foi o bug relatado (issue): o `update.sh` para na etapa do
* banco e o rollback `--force` falha no mesmo ponto.
*
* A correção é o `select distinct on (m.model_id) ... order by m.model_id,
* m.input_price_per_million_cents asc`, que devolve UMA linha por `model_id`
* (escolhendo deterministicamente o provedor de menor preço em caso de empate)
* e preserva a idempotência com o `not exists` existente.
*
* Este teste lê o texto do `baseline.sql` e afirma que o bloco deduplica por
* `model_id`. Ele roda na malha unitária (não exige Postgres/Docker), então pega
* a regressão antes mesmo do `test:db` de CI; e é a CATRACA do fix: reverter o
* bloco para `select` cru (sem `distinct on`) deixa este arquivo VERMELHO.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const RAIZ = process.cwd();
const BASELINE = readFileSync(join(RAIZ, "supabase", "baseline.sql"), "utf8");
/** Extrai o corpo do primeiro `insert into public.ai_pricing (model, prompt_cents...` até o `;` que o fecha. */
function blocoDoBackfillDePrecos(): string {
const inicio = BASELINE.indexOf(
"insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes)",
);
expect(inicio, "bloco do backfill 0068 não encontrado no baseline.sql").toBeGreaterThan(-1);
const fim = BASELINE.indexOf(";", inicio);
expect(fim, "bloco do backfill sem fechamento ';'").toBeGreaterThan(inicio);
return BASELINE.slice(inicio, fim);
}
describe("o backfill 0068 deduplica por model_id", () => {
const bloco = blocoDoBackfillDePrecos();
it("o bloco existe e é o das strings 'backfill 0068 a partir de ai_models'", () => {
expect(bloco).toContain("'backfill 0068 a partir de ai_models'");
});
it("o select usa `distinct on (m.model_id)` para UMA linha por model_id", () => {
expect(
bloco,
"ai_models é único por (provider, model_id): SEM distinct on (m.model_id), o MESMO " +
"model_id sob dois provedores gera DUAS linhas no mesmo INSERT e a PK ai_pricing_pkey " +
"(só model) recusa — o update.sh para na etapa do banco (issue #1998).",
).toMatch(/select distinct on \(m\.model_id\)/);
});
it("o order by escolhe deterministicamente o provedor de menor preço no empate", () => {
expect(
bloco,
"o `order by` precisa ter model_id primeiro (domínio do distinct on) e " +
"input_price em segundo, para escolher o provedor mais barato em caso de empate " +
"de forma determinística.",
).toMatch(/order by m\.model_id,\s*m\.input_price_per_million_cents asc\b/);
});
it("a idempotência do `not exists` é preservada", () => {
expect(bloco).toContain("not exists");
expect(bloco).toContain("p.model = m.model_id and p.superseded_at is null");
});
it("só existe UM bloco desses no baseline (dump + apêndice), e é o único INSERT de preço de modelo", () => {
const ocorrencias = BASELINE.split(
"insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes)",
).length;
expect(ocorrencias).toBe(2); // 1 (antes) + 1 (a própria ocorrência)
});
});