Catalog · How the catalog works
pascal-redis-faa
Cliente Redis (RESP2/RESP3) para Free Pascal/Lazarus e Delphi, numa unica codebase. Pool de conexoes, pipelining, transacoes, scripting, pub/sub, streams e TLS (SChannel no Windows; OpenSSL opt-in). MIT.
- ★ 2 on GitHub
Anyone may press Interesting, signed in or not. It helps others find this project.
README
pascal-redis-faa
Cliente Redis (protocolo RESP2/RESP3) para Free Pascal/Lazarus e Delphi, numa única codebase, escrito do zero. Licença MIT.
Status: v1 completo (M8 — streams). Dá para conectar, autenticar, executar qualquer comando do Redis, usar pipeline, trabalhar com um pool de conexões com timeout de verdade, chamar os comandos de Keys, Strings, Hashes, Lists, Sets, ZSets e Streams pela fachada tipada — inclusive os bloqueantes — em RESP2 ou RESP3, cifrar tudo isso com TLS, usar
MULTI/EXEC/WATCHe scripts Lua com cache de SHA, publicar e assinar canais, e montar fila de trabalho com consumer groups. O que falta são os demais samples GUI (M9) e a validação em Linux (M10). O roadmap completo está emCLAUDE.md.
Projeto irmão da pascal-amqp-faa (cliente AMQP 0-9-1) e da
pascal-pipes-faa (IPC), com as mesmas regras: codebase dual FPC 3.2.2 + Delphi 12,
sem dependências além da pascal-common-faa (de onde vêm a
concorrência e, desde a 0.4.0, o TCP e o TLS), TLS nativo em Windows (SChannel) e OpenSSL
em qualquer plataforma.
O que o v1 entrega
| Área | Conteúdo |
|---|---|
| Núcleo | codec RESP2/RESP3, conexão, pool de conexões, timeouts, reconexão |
| Comandos | Keys, Strings, Hashes, Lists, Sets, ZSets, Streams |
| Avançado | pipelining, MULTI/EXEC/WATCH, scripting (EVAL/EVALSHA com cache de SHA) |
| Mensageria | Pub/Sub (conexão dedicada) e Streams com consumer groups |
| Segurança | TLS via SChannel (Windows) ou OpenSSL (qualquer plataforma), escolhido em runtime; CA privada e mTLS |
Comando de servidor (PING, INFO, CONFIG, DBSIZE) ainda não tem fachada própria —
sai pelo Execute genérico, que alcança qualquer comando do Redis.
Fora do v1: Redis Cluster (redirects MOVED/ASK), Sentinel e client-side
caching (CLIENT TRACKING).
Compatibilidade de servidor
A lib fala RESP, não depende da implementação: funciona com Redis, Valkey,
KeyDB e Dragonfly. O docker/docker-compose.yml usa redis:7.2-alpine por
padrão.
Este projeto não é afiliado à Redis Ltd.; "Redis" é marca de seus respectivos detentores e aparece aqui apenas para identificar o protocolo com que a lib fala.
Servidor de desenvolvimento
cd docker
docker compose up -d # Redis em localhost:6379
docker compose exec redis redis-cli pingPara o listener TLS (6380), gere a PKI de desenvolvimento com sh tools/gen_certs.sh
(ver docker/certs/README.md) e suba com o override:
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -dUso
uses
SysUtils, Redis.Types, Redis.Connection;
var
LParams: TRedisParams;
LConn: TRedisConnection;
LPipe: TRedisPipeline;
LRespostas: TRedisReplyArray;
begin
LParams := RedisDefaultParams; // localhost:6379, banco 0, RESP2
LParams.ClientName := 'minha-app'; // aparece no CLIENT LIST do servidor
LConn := TRedisConnection.Create(LParams);
try
LConn.Open;
LConn.Execute('SET', ['usuario:1:nome', 'Fabiano', 'EX', 3600]);
WriteLn(LConn.Execute('GET', ['usuario:1:nome']).AsString);
// Chave ausente devolve nulo — que NÃO é o mesmo que string vazia.
if LConn.Execute('GET', ['usuario:2:nome']).IsNull then
WriteLn('não existe');
// Pipeline: N comandos numa ida e volta só.
LPipe := TRedisPipeline.Create;
try
LPipe.Queue('INCR', ['visitas']);
LPipe.Queue('LPUSH', ['eventos', 'login']);
LPipe.Queue('LLEN', ['eventos']);
LRespostas := LConn.ExecutePipeline(LPipe);
WriteLn('visitas: ', LRespostas[0].AsInteger);
finally
LPipe.Free;
end;
finally
LConn.Free; // o Close também fecha; o destrutor cobre o caminho de exceção
end;
end.Fachadas tipadas por família
TRedisClient é o objeto que a aplicação segura: pool por dentro, comandos tipados por
fora. Não há Connect — a conexão abre no primeiro comando.
uses
SysUtils, Redis.Types, Redis.Client, Redis.Commands, Redis.Commands.Strings;
var
LClient: TRedisClient;
LOpcoes: TRedisSetOptions;
LSessao: IRedisReply;
LTopo: TRedisStringArray;
LJson, LToken, LValor: string;
begin
LClient := TRedisClient.Create(RedisDefaultParams);
try
LClient.Strings.SetEx('cache:usuario:1', 3600, LJson);
// Ausente e vazio são coisas diferentes: TryGet devolve a presença.
if LClient.Strings.TryGet('cache:usuario:1', LValor) then
WriteLn(LValor);
// Lock distribuído: SET NX PX devolve nulo quando outro já tem a posse.
// Numa aplicação, o TRedisLock faz isto e a liberação (ver "Lock distribuído").
LOpcoes := RedisDefaultSetOptions;
LOpcoes.Condition := scNotExists; // NX
LOpcoes.Expiry := seMilliseconds; // PX
LOpcoes.ExpiryValue := 30000;
if not LClient.Strings.SetWithOptions('lock:pedido:7', LToken, LOpcoes).IsNull then
WriteLn('a posse é minha');
LClient.Hashes.HSetMany('sessao:42', ['ip', '10.0.0.1', 'user', 'ana']);
LSessao := LClient.Hashes.HGetAll('sessao:42');
WriteLn(LSessao.ValueByKey('user').AsString);
LClient.ZSets.ZAdd('ranking', 1500, 'fabiano');
LTopo := LClient.ZSets.ZRevRange('ranking', 0, 9); // top 10
LClient.Keys.Expire('sessao:42', 900);
finally
LClient.Free;
end;
end.Cada comando pega uma conexão do pool e a devolve antes de retornar, o que torna o mesmo
TRedisClient seguro para várias threads. Quando a sequência precisa da mesma conexão
(SELECT, e mais adiante MULTI/WATCH), amarre um cliente a uma conexão emprestada:
LConn := LClient.Acquire;
try
LDedicado := TRedisClient.CreateOnConnection(LConn);
try
... // tudo pela MESMA conexão
finally
LDedicado.Free;
end;
finally
LClient.Release(LConn);
end;Convenção de retorno, válida em todas as famílias: escalar vira tipo nativo (Boolean,
Int64, Double, string); resposta que pode ser nula vira IRedisReply ou tem um
par TryXxx com out, porque "chave ausente" e "chave que vale zero" não podem virar o
mesmo valor; lista vira TRedisStringArray. Chaves, campos e valores entram como
TRedisArg, que aceita string e TBytes na mesma assinatura — a API é
binário-segura sem sobrecarga duplicada.
Comandos bloqueantes
BLPOP, BRPOP e BLMOVE não saem do pool comum: um worker esperando 30 s por uma
tarefa seguraria uma conexão de que as outras threads precisam e, pior, morreria de
timeout de socket antes de o comando terminar — deixando a resposta a caminho para
contaminar a próxima conexão. A fachada os manda por um pool separado, com o read timeout
esticado para além do prazo do comando.
// False não é erro: é o worker ocioso. Com várias chaves, LChave diz de qual veio.
while not GParar do
if LClient.Lists.BLPop(['fila:alta', 'fila:baixa'], 5, LChave, LTarefa) then
Processa(LChave, LTarefa);Prazo zero significa esperar para sempre — só faz sentido em thread dedicada, porque a única forma de cancelar é derrubar a conexão.
Pool de conexões
O Redis não tem canal: uma conexão processa um comando por vez. A unidade de concorrência é a conexão, e o que a aplicação segura é o pool.
uses
Redis.Types, Redis.Connection, Redis.Pool;
var
LPool: TRedisPool;
LConn: TRedisConnection;
begin
LPool := TRedisPool.Create(RedisDefaultParams); // teto de 10 conexões
try
LConn := LPool.Acquire;
try
LConn.Execute('INCR', ['visitas']);
finally
LPool.Release(LConn); // devolver SEMPRE, inclusive em exceção
end;
finally
LPool.Free;
end;
end;O pool descarta (em vez de reciclar) toda conexão que voltar invalidada, suja — sobrou
byte no buffer, o que contaminaria o próximo comando — ou com o banco trocado por um
SELECT. Conexão parada há mais que HealthCheckAfterIdleMs leva um PING antes de ser
emprestada, porque quem derruba conexão ociosa é o servidor e o cliente não é avisado. No
teto, Acquire espera uma devolução e depois levanta ERedisPoolExhausted, em vez de abrir
socket sem limite até o servidor recusar todo mundo.
Não existe "retomar" uma conexão morta: o que o pool faz é abrir outra, e o handshake dela
replaya HELLO/AUTH, CLIENT SETNAME e SELECT. O comando que estava em voo não é
repetido — INCR e LPUSH não são idempotentes.
Timeouts
ReceiveTimeoutMs e SendTimeoutMs (5 s por padrão) viram SO_RCVTIMEO/SO_SNDTIMEO no
socket. Estourado o prazo, o comando levanta ERedisTimeout e a conexão é invalidada —
a resposta atrasada ainda pode chegar, e reciclar essa conexão entregaria essa resposta ao
comando seguinte. Sem esse timeout, um servidor que emudece prende a conexão e a thread que
chamou para sempre: o Redis não tem heartbeat.
Comandos bloqueantes precisam de um timeout maior que o do próprio comando. A fachada já
cuida disso (seção "Comandos bloqueantes"); quem for direto na conexão usa
TRedisConnection.SetReceiveTimeout numa conexão fora do pool.
Execute alcança qualquer comando, presente ou futuro. As fachadas tipadas por família são
uma camada de conveniência por cima — nunca um pré-requisito, e nunca um limite: um comando
que a lib ainda não modelou continua a uma linha de distância.
Binário por contrato. Argumento TBytes vai byte a byte para o fio e AsBytes
devolve o valor cru; as sobrecargas string passam por UTF-8. Um valor com CRLF, zero ou
0xFF no meio sobrevive ao round-trip.
Erros. Erro do servidor (WRONGTYPE, NOSCRIPT, NOAUTH…) levanta
ERedisReplyError, que traz o Code pronto para testar, e a conexão continua utilizável.
Erro de I/O levanta ERedisConnectionLost e invalida a conexão: o comando em voo não é
reexecutado, porque INCR, LPUSH e SETNX não são idempotentes — a decisão de repetir é
de quem chamou. ExecuteRaw devolve o erro como resposta, sem levantar, e o pipeline nunca
levanta por erro de servidor (cada item pode ser um erro).
RESP3 é opt-in: LParams.Protocol := rpRESP3 faz o handshake com HELLO 3. O código da
aplicação não muda — um HGETALL tem a mesma forma nos dois protocolos, porque o mapa do
RESP3 é guardado achatado.
TLS
O TCP e o TLS vêm da pascal-common-faa (desde a 0.4.0). O backend é escolhido em
runtime, por conexão, em TRedisParams.TlsBackend — e nunca por tentativa e queda: um
cliente que tenta um motor e cai para outro acaba usando, na máquina do usuário, um backend
diferente do que foi testado.
TlsBackend |
Backend | Onde |
|---|---|---|
tbDefault |
SChannel no Windows, OpenSSL fora dele; OpenSSL em qualquer lugar num build com -dREDIS_OPENSSL |
|
tbSChannel |
SChannel (SSPI) | só Windows, sem DLL para distribuir; fora dele levanta ERedisTls |
tbOpenSsl |
OpenSSL (libssl/libcrypto 3.x ou 1.1.1) | qualquer plataforma, carregado na primeira conexão |
RedisTlsBackendName diz qual motor o padrão deste build é; RedisTlsBackendInfo acrescenta
o detalhe de runtime que o OpenSSL fornece — versão e a biblioteca que realmente carregou,
que é o que resolve o dia em que a máquina tem três OpenSSL instalados e o errado venceu.
uses
Redis.Types, Redis.Client;
var
LParams: TRedisParams;
begin
LParams := RedisDefaultTlsParams; // porta 6380 E UseTls, de uma vez
LParams.TlsCaFile := 'ca.crt'; // CA privada (PEM); vazio = repositório do sistema
LClient := TRedisClient.Create(LParams);CA privada e mTLS. TlsCaFile troca o repositório do sistema por uma CA em PEM (o caso de
quase todo Redis auto-hospedado), nos dois backends, com a validação ligada. Para servidor com
tls-auth-clients, o certificado do cliente vai em TlsCertFile — no formato do backend: par
PEM (TlsCertFile + TlsKeyFile) no OpenSSL, PFX com senha (TlsCertFile +
TlsCertPassword) no SChannel.
TLS no Redis não é upgrade em banda. Não existe STARTTLS: o servidor abre um
listener separado (--tls-port), e a conexão nasce cifrada ou não nasce. Por isso
RedisDefaultTlsParams troca a porta junto com o UseTls — ligar um sem o outro manda
um ClientHello para o listener de texto claro, que o lê como comando inline e nunca
responde. Nesse caso o que salva é o prazo do handshake (o ReceiveTimeoutMs), e a lib
levanta ERedisTimeout
nomeando a troca de porta como causa provável: a pista natural seria procurar defeito
no certificado, e o erro está no número da porta.
TlsVerifyPeer := False aceita qualquer certificado — mantém a cifragem e joga fora a
defesa contra man-in-the-middle. A lib não oferece nenhum atalho que já venha com a
validação desligada: RedisDefaultTlsParams mantém TlsVerifyPeer := True, e quem
precisa baixá-la escreve LParams.TlsVerifyPeer := False numa linha sua. Com uma CA privada,
o certo é TlsCaFile, não desligar a validação. É proposital — assim a decisão aparece no diff de
quem a tomou e num grep de quem for auditar, em vez de atravessar para produção
escondida num nome de função amigável.
Para exercitar contra o servidor de desenvolvimento, gere a PKI (sh tools/gen_certs.sh),
suba o override e rode o smoke test cifrado:
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
SmokeTest.exe --tls--tls não é um modo com meia dúzia de passos extras: a bateria inteira passa a rodar
por cima da criptografia, validando o servidor contra a CA de docker/certs — que é onde um
envelope TLS mal-feito aparece: bulk de 512 KB atravessando vários registros TLS, pipeline
numa escrita só, timeout de socket no meio de um registro. A seção de TLS acrescenta, por
backend (os dois no mesmo build, no Windows), a CA privada, a CA errada e o mTLS aceito e
recusado.
Transações (MULTI/EXEC/WATCH)
A palavra engana: uma transação do Redis garante que ninguém intercala comando no meio
do bloco — e só isso. Ela não desfaz nada quando um comando falha, e não deixa ler nada
lá dentro. É esse segundo buraco que o WATCH preenche, por fora do bloco:
LTx := LClient.BeginTransaction; // empresta uma conexão do pool
try
LTx.Watch(['saldo']); // vigia
LSaldo := LTx.Connection.Execute('GET', ['saldo']).AsInteger; // lê (fora do bloco)
if LSaldo < 100 then
Exit; // decide
LTx.Queue('SET', ['saldo', LSaldo - 100]); // enfileira
if not LTx.TryCommit(LRespostas) then
; // alguém mexeu em 'saldo' entre o WATCH e o EXEC: recomece o ciclo
finally
LTx.Free; // devolve a conexão ao pool
end;TryCommit devolvendo False não é erro — é o funcionamento normal do check-and-set sob
concorrência, e a resposta certa quase sempre é repetir o ciclo. (Commit existe para
quando não há WATCH: ele levanta ERedisTransactionAborted no lugar.)
Não há rollback. Se um comando do bloco falhar com WRONGTYPE, os outros rodaram e
ficaram gravados; o erro vem como item rkError no array de respostas e Commit não
levanta por isso. Levantar faria você concluir que nada foi gravado, quando quase tudo foi.
Para tudo-ou-nada de verdade, use um script Lua.
O bloco inteiro sai numa única ida e volta (MULTI + comandos + EXEC num pipeline),
e não N+2. Como o MULTI só sai no commit, Discard nem precisa falar com o servidor — o
que ele manda é o UNWATCH. E o try/finally não é decoração: enquanto a transação viver,
aquela conexão está fora de circulação, porque WATCH é estado da conexão.
Scripting (EVAL/EVALSHA)
Um script Lua é a única atomicidade real do Redis: ele lê, decide e escreve numa passagem só, e nada mais roda no servidor enquanto isso — com a contrapartida óbvia de que script demorado trava o servidor inteiro.
O exemplo que não existe sem script é o release de lock distribuído:
const
LIBERA =
'if redis.call("GET", KEYS[1]) == ARGV[1] then' + sLineBreak +
' return redis.call("DEL", KEYS[1])' + sLineBreak +
'else' + sLineBreak +
' return 0' + sLineBreak +
'end';
// 1 quando o lock era seu e foi liberado; 0 quando já era de outra pessoa.
LClient.Scripting.Run(LIBERA, ['lock:pedido:7'], [LMeuToken]).AsInteger;Fazer isso com GET seguido de DEL é a corrida clássica: entre os dois, o lock pode
expirar e ser tomado por outro — e o DEL apagaria o lock dele.
Run cuida do cache de SHA sozinha: calcula o SHA-1 localmente (sem round-trip), manda
EVAL na estreia — o servidor executa e guarda — e EVALSHA da segunda em diante,
trocando kilobytes de Lua por 40 bytes. Se o servidor tiver esquecido o script
(SCRIPT FLUSH, restart, failover para uma réplica), ele responde NOSCRIPT e a lib
reenvia o EVAL sozinha: o cache é uma otimização que se autocorrige, nunca uma
suposição sobre o estado do servidor. Eval, EvalSha, ScriptLoad, ScriptExists e
ScriptFlush continuam disponíveis para quem quiser controlar o ciclo à mão.
Lock distribuído (TRedisLock)
Os dois trechos acima (o SET NX PX com token e o script que libera) são o que toda
aplicação copiava, junto com uma regra que ninguém escrevia igual duas vezes: Redis
indisponível não é "ocupado". Desde a 0.3.0 eles moram na unit Redis.Lock:
uses
Redis.Client, Redis.Lock;
LLock := TRedisLock.Create(LClient, 'app:lock:'); // o cliente não é do lock
case LLock.Acquire('pedido:7', 10000, LToken) of
rlAcquired:
try
... // a posse é sua por até 10 s
finally
LLock.Release('pedido:7', LToken); // só apaga se ainda for sua
end;
rlBusy:
... // outro tem a posse
rlUnavailable:
... // o Redis não respondeu: seguir sem lock (se outra coisa garante) ou recusar
end;- O token é aleatório (32 hex), um por aquisição;
ReleaseeExtend(novo prazo) rodam os scripts que comparam o token antes de agir (REDIS_LOCK_RELEASE_SCRIPT,REDIS_LOCK_EXTEND_SCRIPT). - Só três exceções são indisponibilidade:
ERedisConnectionLost,ERedisTimeouteERedisPoolExhausted(RedisIsUnavailable).Acquireas devolve comorlUnavailable; qualquer outro erro (WRONGTYPE, um script recusado) é bug e sobe.TryAcquireé o atalho booleano, e nele a indisponibilidade levanta. Numa liberação, o Redis fora viraFalse: a chave vence sozinha peloPX. OnUnavailablerecebe a operação, a chave (com o prefixo) e a exceção — nunca o token, que liberaria o lock de outro.- É lock de instância única (um Redis só), não Redlock: se o dono pausar por mais que o prazo, outro pode adquirir. Onde isso importa, o lock só poupa trabalho e a palavra final é de outra coisa (a pascal-agenda-faa, de onde ele veio, deixa o banco decidir o horário).
Cache (TRedisCacheStore)
A pascal-common-faa (1.29.0) tem um cache com prazo e invalidação por tag
(PascalCommon.Cache): o TPcCache guarda as regras e um store guarda os dados. Desde a 0.5.0,
a unit Redis.Cache traz o store Redis, compartilhado por todas as instâncias da aplicação:
uses
PascalCommon.Cache, Redis.Pool, Redis.Cache;
LStore := TRedisCacheStore.Create(LPool); // o pool não é do store; contadores de tag com TTL de 1 dia
FCache := TPcCache.Create(LStore, 'app');
LChave := FCache.Key('produtos:' + IntToStr(LCategoria), ['categoria:' + IntToStr(LCategoria)]);
if not FCache.TryGet(LChave, LJson) then
begin
LJson := CarregaDoBanco(LCategoria);
FCache.Put(LChave, LJson, 300); // segundos
end;
...
FCache.Invalidate(['categoria:7']); // depois do commit- Valores são
string, gravados e lidos como UTF-8, byte a byte.PutéPSETEX. - Cada tag é um contador. Um contador ausente nasce no horário Unix em ms, nunca em 0, então um
contador que sumiu (expirou, eviction, restart sem persistência) volta maior do que já foi e
as entradas antigas não são lidas de novo.
Invalidatesó incrementa contador que existe (script Lua). - Os contadores expiram (desde a 0.6.0; até a 0.5.0 ficavam para sempre): nascem com
SET NX PX, com o TTL do segundo parâmetro do construtor,CounterTtlMs(padrão: um dia,REDIS_CACHE_COUNTER_TTL_MS). Ler não renova o prazo, eInvalidateo mantém. Assim uma tag que vem de entrada do cliente (um id qualquer) não deixa uma chave no Redis para sempre. O preço é um miss por chave quando o contador dela expira; por isso oCounterTtlMsdeve ser maior que o TTL dos valores. A garantia vale enquanto os relógios das instâncias diferirem bem menos que oCounterTtlMs. - Redis fora é miss, não erro: conexão, timeout, pool esgotado ou fechado e TLS viram
EPcCacheUnavailable, e oTPcCachesegue sem o cache (avisando oOnError). Erro de resposta (WRONGTYPE) é bug e sobe. - A unit exige a pascal-common-faa 1.29.0. As regras de uso (invalidar depois do commit,
não guardar o que uma transação que escreveu leu) estão no
docs/cache-design.mdde lá.
Pub/Sub
Publicar é um comando comum — sai por qualquer conexão do pool:
uses
Redis.Types, Redis.Client, Redis.Commands.PubSub;
// Devolve quantos assinantes receberam NAQUELE instante. Zero quer dizer que a
// mensagem evaporou: pub/sub não guarda nada para quem chegar depois.
LClient.PubSub.Publish('noticias', 'saiu a edição de hoje');Assinar é outra história: em RESP2 o SUBSCRIBE sequestra a conexão, então o assinante
tem conexão dedicada, fora do pool, e uma thread lendo o que o servidor manda.
uses
SysUtils, Redis.Types, Redis.Client, Redis.PubSub;
type
TOuvinte = class
procedure Chegou(ASender: TObject; const AMensagem: TRedisPubSubMessage);
end;
procedure TOuvinte.Chegou(ASender: TObject; const AMensagem: TRedisPubSubMessage);
begin
// Roda na THREAD DE LEITURA, uma mensagem por vez, na ordem em que chegaram.
// Numa aplicação GUI, marshale para a main thread (TThread.Queue).
WriteLn(AMensagem.Channel, ': ', AMensagem.Text);
end;
var
LSub: TRedisSubscriber;
begin
LSub := LClient.CreateSubscriber; // mesmos parâmetros de conexão do cliente
try
LSub.OnMessage := LOuvinte.Chegou;
LSub.Start; // abre a conexão e sobe a thread
// Só volta depois que o servidor confirmou: publicar na linha seguinte não
// é corrida.
LSub.Subscribe(['noticias', 'alertas']);
LSub.PSubscribe(['noticias.*']); // glob, casado pelo servidor
... // a aplicação segue a vida
finally
LSub.Free; // para a thread e fecha a conexão
end;
end;O que a lib garante e o que não garante:
- Ordem, sim. O
OnMessageroda na thread de leitura, uma mensagem por vez. É por isso que callback lento segura o socket: trabalho pesado deve ir para uma fila da aplicação. Exceção que escape do callback vai para oOnErrore não derruba a conexão. - Entrega, não. Mensagem publicada enquanto o assinante estava fora do ar está perdida — sem fila, sem replay. Quem precisa de entrega garantida usa Streams com consumer group (seção seguinte).
- Reconexão, sim (
AutoReconnect, ligado por padrão): a conexão volta e as assinaturas são reenviadas, comOnDisconnected/OnReconnectedavisando. O que se perdeu no intervalo continua perdido.
Em RESP2, com assinatura ativa, a conexão só aceita comandos de assinatura, PING,
RESET e QUIT — e a lib recusa os outros antes de irem ao fio, com uma mensagem que
diz o que fazer. Em RESP3 (LParams.Protocol := rpRESP3) as mensagens chegam por um
tipo próprio (push) e a mesma conexão continua servindo comando comum:
LSub.Subscribe(['noticias']);
LSub.Execute('SET', ['ultima-leitura', '2026-08-23']); // só vale em RESP3PUBSUB CHANNELS, NUMSUB e NUMPAT respondem pelo lado de quem publica
(LClient.PubSub.ActiveChannels, CountSubscribers, NumPatterns) — úteis para
diagnóstico, não para lógica de aplicação: a resposta envelhece no caminho de volta.
Streams e consumer groups
Stream é o único tipo do Redis com entrega confiável. A diferença para o pub/sub cabe numa frase: ali, mensagem publicada sem assinante no ar evapora; aqui, ela fica gravada, e o consumer group ainda registra quem a recebeu e se ela foi confirmada.
Gravar e ler é um log append-only com id crescente:
uses
Redis.Types, Redis.Client, Redis.Commands.Streams;
var
LId: string;
LEntradas: TRedisStreamEntryArray;
begin
// '*' pede o próximo id ao servidor: <ms>-<sequência>, sempre crescente.
LId := LClient.Streams.XAdd('eventos', ['tipo', 'venda', 'valor', '199.90']);
// MAXLEN ~ 1000 mantém "mil e pouco" entradas — muito mais barato que o
// corte exato, e é o que se usa em produção.
LClient.Streams.XAddMaxLen('eventos', 1000, True, ['tipo', 'login']);
LEntradas := LClient.Streams.XRange('eventos',
REDIS_STREAM_MIN_ID, REDIS_STREAM_MAX_ID);
WriteLn(LEntradas[0].FieldValue('tipo')); // campos por nome
end;A fila de trabalho é o consumer group. Cada entrada vai para um consumidor do grupo, e
fica pendente (na PEL, a pending entries list) até o XACK:
var
LDados: TRedisStreamDataArray;
I: Integer;
begin
// MKSTREAM cria a chave se ela ainda não existe — o caso de todo consumidor
// que sobe antes do produtor. TryCreate devolve False se o grupo já existia,
// então todo worker pode chamar isto na subida sem try/except.
LClient.Streams.XGroupTryCreate('eventos', 'processadores', '0', True);
// '>' é o que nunca foi entregue a ninguém do grupo. BLOCK é em
// MILISSEGUNDOS (o timeout do BLPOP é em segundos — não confunda), e sai por
// uma conexão fora do pool comum.
LDados := LClient.Streams.XReadGroupBlocking('processadores', 'worker-1',
['eventos'], [REDIS_STREAM_NEW], 5000, 10);
if Length(LDados) > 0 then
for I := 0 to High(LDados[0].Entries) do
begin
Processa(LDados[0].Entries[I]);
// Sem XACK a entrada fica pendente para sempre: é o vazamento clássico
// de consumer group.
LClient.Streams.XAck('eventos', 'processadores',
[LDados[0].Entries[I].Id]);
end;
end;Worker que morre no meio deixa a entrada na PEL, e é isso que torna a entrega confiável. Recolher o trabalho abandonado é um comando só:
var
LProximo: string;
LEntradas: TRedisStreamEntryArray;
begin
// Reivindica o que está parado há mais de 60 s. O mínimo de ociosidade é a
// proteção contra dois workers processando a mesma entrada: quem está
// trabalhando há 200 ms não é roubado.
LEntradas := LClient.Streams.XAutoClaim('eventos', 'processadores',
'worker-2', 60000, '0-0', 100, LProximo);
// LProximo é cursor, com a mecânica do SCAN: repita até voltar '0-0'.
end;Três detalhes que a lib absorve e que costumam morder:
XREAD/XREADGROUPmudam de forma entre RESP2 e RESP3 (lista de pares contra mapa). A fachada devolve sempreTRedisStreamDataArray, então a aplicação não ramifica por protocolo. Chave sem novidade não aparece na resposta — useRedisFindStreamDatapara achar pelo nome, nunca pela posição da chamada.- Entrada pode vir sem campos.
XDELtira do stream mas não da PEL: reler a PEL alcança ids que já não existem, e aíFieldsénil(IsDeleted). Não é erro — é o estado normal de uma pendência sobre entrada apagada. '>'contra'0'.XREADGROUPcomREDIS_STREAM_NEWpede o que nunca foi entregue e cria pendência; comREDIS_STREAM_PENDINGrelê a PEL deste consumidor, que é como um worker retoma o próprio trabalho depois de reiniciar. Trocar um pelo outro é o erro clássico.
XPendingSummary/XPendingRange respondem quem está devendo o quê (com ociosidade e
contador de entregas — contador alto denuncia mensagem venenosa, que derruba todo worker
que a pega), e XInfoStream/XInfoGroups/XInfoConsumers entregam a introspecção do
servidor como mapa achatado.
Spans (OpenTelemetry)
Quando a aplicação liga o tracer da pascal-common-faa (TPcTracing.Start, da unit
PascalCommon.Tracing, com um exportador como o OTLP da
pascal-api-infra-faa), a lib abre os
spans sozinha. Não há nada para configurar aqui: eles entram na trace do span corrente da
thread, que numa API é o span de servidor da requisição.
uses PascalCommon.Tracing;
TPcTracing.Start(TPcTracingOptions.FromEnvironment('pedidos', '1.0.0'), LExportador);
...
LCliente.Strings.SetValue('sessao:42', LToken); // span client 'SET'| Span | Kind | Quando | Atributos |
|---|---|---|---|
o comando (GET, XREADGROUP; com o subcomando nos containers: CLIENT SETNAME, XGROUP CREATE) |
client | todo Execute/ExecuteRaw/ExecuteArgs, logo, toda fachada |
db.system.name (redis), db.operation.name, db.query.text, db.namespace (o índice do banco), server.address, server.port |
PIPELINE |
client | um ExecutePipeline |
os mesmos, sem db.query.text, mais db.operation.batch.size |
MULTI |
client | uma transação (TRedisTransaction, que vai num pipeline só) |
idem; o tamanho conta só os comandos de dentro |
pool wait |
internal | um Acquire que precisou esperar por conexão livre, da primeira espera até conseguir ou desistir |
pascalredis.pool.wait_attempts, pascalredis.pool.max_size, db.system.name |
db.query.textleva só o comando e a primeira chave, com o resto trocado por?:SET sessao:42 ?. Valor no Redis é com frequência segredo (token, sessão, a senha de umAUTH). Comando sem chave (PING,INFO,AUTH,HELLO,CONFIG SET) não mostra argumento nenhum. NoEVAL/EVALSHAa chave é a primeira depois donumkeys; noXREAD/XREADGROUP, a primeira depois deSTREAMS.- Erro do servidor é status de erro, com o código (
WRONGTYPE,NOSCRIPT) emdb.response.status_codeeerror.type, inclusive noExecuteRaw, que não levanta. Isso vale também para os erros que a lib trata como recado: oNOSCRIPTque oScripting.Runreenvia comoEVALe oBUSYGROUPdoXGroupTryCreateaparecem como um span com erro. Queda, timeout e fluxo malformado põem a classe da exceção emerror.type. Num lote, os itens com erro (noMULTI, também os de dentro do array doEXEC) contam empascalredis.batch.error_count. - Sem span: o handshake (
HELLO/AUTH,CLIENT SETNAME,SELECT), oPingdo health check do pool (Execute('PING', [])tem span) e a conexão do assinante de pub/sub: a mensagem publicada no Redis não carrega header, então não haveria contexto para propagar, e cada mensagem viraria uma trace solta. OPUBLISHtem span como qualquer comando. - Desligado, não custa nada: com
TPcTracing.Enabledfalso não se cria span, nem os ids, nem o texto do comando. - Comando bloqueante tem span como os outros, e a duração inclui a espera do servidor.
Build
Dependência: a lib usa a
pascal-common-faa 1.27.0 ou mais
nova — a base compartilhada pelas libs *-faa, de onde vêm o monitor/condvar, os
atomics portáveis, o relógio monotônico, o pool de threads (TPcMonitor, PcAtomic*,
PcTickMs, PcPool) os spans (PascalCommon.Tracing) os bytes aleatórios do token do lock (PascalCommon.Random, desde a 0.3.1) e o TCP e o TLS (PascalCommon.Transport*, desde a 0.4.0). A unit Redis.Cache (0.5.0) pede a 1.29.0, por causa do PascalCommon.Cache, e o pascal_redis_faa.lpk, que compila todas as units, também. Quem fornece a cópia é a sua aplicação, uma só para todas as
libs *-faa que ela usar: no Lazarus, registre o pascal_common_faa.lpk (o
pascal_redis_faa.lpk o exige pelo nome); no Delphi, ponha o src dela no search path.
Uma cópia velha demais para o build com uma mensagem dizendo isso. O submódulo
external/pascal-common-faa deste repositório serve só aos testes e samples:
git submodule update --init external/pascal-common-faaO PcPool é um pool para o processo inteiro, dividido com as outras libs *-faa.
A lib em si não enfileira nada nele (o pub/sub tem thread de leitura própria); quem usa
são os samples GUI.
FPC (linha de comando):
fpc -Fusrc -Fisrc -Fuexternal\pascal-common-faa\src -Fiexternal\pascal-common-faa\src -FEbuild -FUbuild samples\SmokeTest\SmokeTest.dprLazarus: lazbuild packages\pascal_redis_faa.lpk (com o pascal_common_faa.lpk
registrado) e depois os projetos; os .lpi de testes e samples já apontam para o
submódulo e não precisam de registro. O build mode openssl
(lazbuild -B --build-mode=openssl <proj>.lpi) troca o backend TLS padrão (os dois existem
em todo build; ver "TLS").
Delphi: abrir Redis.groupproj no IDE (a Community Edition não compila por
linha de comando). Os projetos já trazem external\pascal-common-faa\src no search path.
Testes unitários
Não precisam de servidor. O codec RESP é exercitado sobre uma fonte de bytes em
memória, que entrega a resposta em pedaços de tamanho controlado para reproduzir
leituras parciais de rede; a conexão inteira (handshake, Execute, pipeline,
invalidação) sobe sobre um servidor falso, também em memória. A lógica do pool
(teto, reuso, descarte, poda por ociosidade, health check reprovando) roda sobre
esse mesmo servidor falso. As fachadas por família são verificadas nas duas metades que
importam: os bytes que foram para o fio (a ordem dos modificadores de SET, ZADD e
ZRANGEBYSCORE não é livre) e a conversão da resposta (nulo que não pode virar '',
WITHSCORES mudando de forma entre RESP2 e RESP3). As transações e o scripting entram
aqui também: o EXEC nulo, o comando recusado na fila e o NOSCRIPT são estados que o
servidor real quase nunca produz sob demanda, mas que o servidor falso entrega de graça.
O pub/sub tem um servidor falso que responde — ele interpreta o SUBSCRIBE e devolve
a confirmação —, porque sem diálogo não há como testar confirmação de assinatura, ordem
das mensagens, callback que levanta nem queda de conexão. Os streams voltam ao roteiro
fixo: o que interessa ali é a montagem do comando (MAXLEN ~ antes do id, STREAMS como
último modificador, BLOCK em milissegundos) e a leitura de respostas que o servidor real
raramente produz sob demanda — entrada apagada que sobrou na PEL, XAUTOCLAIM sem a
lista de apagados do Redis 6.2.
FPC/Lazarus (FPCUnit):
lazbuild -B tests\Unit\fpc\RedisUnitTestsFpc.lpi
tests\Unit\fpc\RedisUnitTestsFpc.exe --all --format=plainDelphi (DUnitX): abrir Redis.groupproj e compilar Redis.UnitTests.
Testes de integração
Precisam do container de pé (seção "Servidor de desenvolvimento"). Cobrem o que não dá para
verificar em memória: read timeout de socket de verdade, conexão derrubada pelo servidor
(CLIENT KILL), conexão contaminada por resposta atrasada, várias threads dividindo o
mesmo pool e uma bateria por família de comandos contra o servidor real — incluindo um
BLPOP com prazo maior que o read timeout e a prova de que HGETALL e ZRANGE WITHSCORES devolvem o mesmo resultado em RESP2 e em RESP3. O pub/sub entra com a
difusão para vários assinantes e com a reconexão: a conexão do assinante é derrubada por
CLIENT KILL e quem confirma que as assinaturas voltaram é o servidor, pelo
PUBSUB NUMSUB, não a contabilidade do cliente. Os streams entram com o ciclo inteiro de
uma fila de trabalho: dois consumidores do mesmo grupo repartindo as entradas, a pendência
que sobrevive ao worker que morreu e volta por XAUTOCLAIM, o XCLAIM que não rouba
trabalho em andamento e o XREAD BLOCK com prazo maior que o read timeout do socket. O store
do cache entra com um contador de tag apagado como um eviction o apagaria, e outro que expira
sozinho, que voltam maiores do que já foram sem que a entrada antiga seja lida, com o TTL do
contador conferido no servidor (nasce com ele, Invalidate o mantém, a leitura não o renova),
e oito threads correndo para criar os mesmos contadores.
TLS fica de fora daqui de propósito: esta suíte tem de valer com só o
docker-compose.yml de pé, sem certificado nenhum. Quem exercita TLS é o smoke
test, com --tls.
lazbuild -B tests\Integration\fpc\RedisIntegrationTestsFpc.lpi
tests\Integration\fpc\RedisIntegrationTestsFpc.exe --all --format=plainNo Delphi, abrir Redis.groupproj e compilar Redis.IntegrationSuite.
Sem parâmetros os executáveis abrem a interface gráfica com a árvore de testes.
Os dois lados do FPC por script, com o heaptrc ligado (o critério inclui
0 unfreed memory blocks):
sh tools/test_fpc.sh # Windows, lazbuild (BUILDMODE=openssl: OpenSSL como padr??o)
sh tools/test_fpc_docker.sh # Linux, FPC 3.2.2 num containerO script do Linux sobe um Redis próprio a cada execução, então dá para rodar vários ao
mesmo tempo, com CPUS=1 RUNS=5, para caçar corrida de concorrência.
As duas suítes têm a mesma cobertura e o corpo dos testes é idêntico — o
tests\Unit\Redis.DUnitXCompat.pas existe para isso. Toda mudança em uma vai
para a outra na mesma sessão.
Smoke test
Precisa do container de pé (seção anterior).
cd samples\SmokeTest
lazbuild SmokeTest.lpi
SmokeTest.exe # 151 passos, texto claro (6379)
SmokeTest.exe --tls # 170 passos no Windows (165 fora dele), tudo cifrado (6380) + a se????o de TLSSai com código 0 se todos os passos passarem, e 2 se receber um argumento
desconhecido — um --tsl digitado errado rodaria em texto claro com cara de
sucesso, que é justo o contrário do que se queria provar.
O --tls precisa do override e da PKI de docker/certs (seção "TLS"); sem o ca.crt, sai
com código 2. No Windows ele passa pelos dois backends no mesmo build; o lazbuild -B --build-mode=openssl SmokeTest.lpi só troca qual é o padrão (o da bateria inteira).
Samples GUI (VCL/LCL)
Cada sample é um app único que compila nos dois mundos a partir do mesmo
fonte — lazbuild <Nome>.lpi no FPC/Lazarus, ou abrir o .dproj no Delphi.
Todos trazem host/porta/senha/banco e um checkbox TLS (marcá-lo troca a porta
junto, porque no Redis TLS não é upgrade em banda: é outra porta).
O princípio dos samples é um mecanismo por app, com as variações do padrão como controles dentro da própria tela — e cada um traz a armadilha do padrão num botão, para dar para reproduzi-la em vez de só ler sobre ela.
CacheAsideVcl
O ciclo completo: miss → busca na fonte lenta → grava com TTL → hit → expira.
A fonte lenta é simulada por um Sleep de tempo editável, porque o que importa
é o ciclo, não de onde o dado vem.
cd samples\CacheAsideVcl
lazbuild CacheAsideVcl.lpi
CacheAsideVcl.exeTrês coisas para experimentar na tela:
- A armadilha do
SET. O botão "Regravar SEM KEEPTTL" grava o valor comStrings.SetValuee o rótulo "TTL restante" pula para SEM PRAZO: umSETsimples apaga o prazo da chave, e um cache regravado assim vira vazamento permanente — o tipo de bug que só aparece semanas depois, como memória que não para de crescer. O botão ao lado faz a mesma gravação comExpiry := seKeepTtle o prazo continua correndo. - A debandada. "Consultar 5x ao mesmo tempo" dispara cinco consultas concorrentes na mesma chave fria: as cinco erram o cache e as cinco vão à fonte. O cache-aside sozinho não protege contra isso, e o log mostra os cinco misses no mesmo milissegundo.
- Expiração em massa. Aquecer 20 chaves com TTL fixo faz a barra despencar de 20 para 0 de uma vez (todas expiram no mesmo segundo, e o lote inteiro de misses cai na fonte junto); com jitter — o mesmo TTL ±20% sorteado por chave — a barra desce aos poucos.
O sample também estabelece o padrão de threads dos samples GUI deste projeto:
como o Redis é request/response, quem chama Strings.Get bloqueia a própria
thread, então nada de rede acontece na thread da UI. Toda operação é um
TPcWorkItem no PcPool, e o resultado volta para a tela por um marshal
descartável + TThread.Queue.
No fechamento, a form espera todos os work items que enfileirou — contados desde o
enfileiramento, não só os que estão falando com o servidor — e roda os marshals que
eles postam enquanto espera. A LCL e a VCL liberam a form antes de qualquer finalização
de unit, e o pool de threads é liberado numa delas: sem essa espera, um item atrasado
rodaria contra a form já liberada (ver a seção 50 de docs/DECISOES.md).
LockDistribuidoVcl
Lock distribuído com SET NX PX e um token de posse, com os comandos crus de propósito:
numa aplicação, use o TRedisLock (acima), que usa os mesmos scripts. Aviso na própria
tela: isto é um lock de instância única (um Redis só, sem coordenação
entre vários) — não é Redlock. Se o processo dono morrer ou pausar por mais
tempo que o TTL, a garantia cai junto.
cd samples\LockDistribuidoVcl
lazbuild LockDistribuidoVcl.lpi
LockDistribuidoVcl.exeTrês coisas para experimentar na tela:
- A armadilha do
DEL. Adquira o lock com um TTL curto, ligue "Simular concorrente tentando o lock" (que fica tentandoSET NX PXa cada 1s) e espere o seu TTL vencer — o concorrente assume o lock com o próprio token. Clicando "Liberar com DEL direto (ARMADILHA)" achando que ainda é seu, o log mostra o lock do concorrente sendo apagado por engano. O botão ao lado, "Liberar com script (compare-and-delete)", faz a mesma liberação com um Lua que só apaga se o token no servidor ainda for o seu — é a única forma segura. - A variante sem token. Desmarcar "Gerar token de posse" grava a chave com
um valor fixo, e o botão "Liberar com script" fica desabilitado: sem token
não há o que comparar, e a única saída é o
DELàs cegas. - Renovação de posse. "Renovar automaticamente" roda a cada 1s um script
que só estende o TTL (
PEXPIRE) se o token no servidor ainda for o seu — mantém a posse sem deixar o prazo estourar. Se alguém já tomou o lock, a renovação falha sozinha e a tela desmarca o checkbox.
FilaTarefasVcl
Fila de trabalho durável com stream + consumer group: XADD produz,
XREADGROUP BLOCK consome, XACK confirma. É o único tipo do Redis com
entrega confiável — mensagem publicada sem assinante no ar evapora no
pub/sub, mas aqui fica gravada, e o grupo registra quem recebeu até alguém
confirmar.
cd samples\FilaTarefasVcl
lazbuild FilaTarefasVcl.lpi
FilaTarefasVcl.exeQuatro coisas para experimentar na tela:
- A armadilha da pendência. "Matar" faz o consumidor receber a próxima tarefa e parar sem confirmar — como um processo que morre no meio do trabalho. A tarefa fica na PEL dele para sempre, até "Reivindicar (XAUTOCLAIM)" transferi-la para o consumidor "recuperador", que processa e confirma.
- A armadilha da entrada apagada — e um achado ao validar contra o
servidor. "Apagar a última tarefa abandonada (XDEL)" tira a entrada do
stream, mas não da PEL. No Redis 7+,
XAUTOCLAIM/XCLAIMpurgam sozinhos da PEL a entrada que já não existe mais no stream — sem nunca devolvê-la comoIsDeleted. Quem mostraTRedisStreamEntry.IsDeletedde verdade é "Retomar minha PEL" (mesmo consumidor relendo a própria PEL comXREADGROUP ... 0): a entrada chega sem campos, e só resta confirmar. - Variação um × dois consumidores. "Consumidor A" e "Consumidor B" são independentes: ligue só um para ver a fila se acumular sem ninguém reivindicar o que ele abandonar, ou os dois para ver o trabalho se repartir e o sobrevivente seguir enquanto o morto fica pendente.
- Consumidor como laço persistente. Ao contrário dos outros samples, cada
consumidor é um laço rodando num worker do
PcPool— fica bloqueado emXReadGroupBlocking, acorda com uma tarefa ou o timeout, e repete até ser desligado. Cada ida ao servidor tem seu próprio parUsarCliente/SoltarCliente, nunca um só cobrindo o laço inteiro — é o que permite desconectar com um consumidor ligado sem esperar o laço inteiro morrer.
Licença
MIT — Copyright (c) 2026 Fabiano Arndt. Ver LICENSE.

