Executando verificação de segurança...
2

Estratégia de rollback para reduzir danos em Produção

Com a popularização do uso de agentes de IA e com o advento de novas interfaces de interação homem-máquina, como por exemplo, o protocolo MCP, tem ficado cada vez mais claro que novas estratégias e garantias precisam existir para permitir que nossos sistemas possam acompanhar essa mudança sem desmoronar esse intrincado castelo de cartas.
Quando se tem contato com sistemas de alto throughput e escala, fica muito mais fácil ver um pequeno acidente de percurso se tornar um verdadeiro desastre.
Imagine o seguinte cenário:

Você trabalha com um marketplace que possui um recurso de API que permite editar dados de produtos, o que é bem comum em um sistema dessa natureza. Um usuário deste sistema decide usar a nova interface que vocês disponibilizaram, que permite que com algumas instruções de texto, esse cliente possa editar dados dos produtos que ele tem no catálogo pessoal do marketplace. Porém, a edição da descrição fica toda incorreta, e o pior, afeta 1000 produtos do catálogo desse cliente... EM PRODUÇÃO 😨

Certamente este cliente vai abrir um ticket para o suporte dizendo:

"Eu não sei o que aconteceu, mas o sistema bagunçou todo meu catálogo"

E após verificar os logs, você e seu time descobrem que na verdade o usuário interagiu com essa nova funcionalidade que consumiu tools do MCP server que vocês disponibilizaram, e essas tools permitiam a atualização do catálogo de forma indiscriminada.

E agora? Supondo que seu time de Ops é muito bom, e vocês mantém snapshots frequentes do banco de dados, vocês precisarão alocar algumas horas para subir um clone do snapshot anterior à mudança e escrever alguns scripts para reconciliar esses dados com o banco em produção (o que geralmente cria um certo nível de tensão e risco).
Vocês podem inclusive, desenvolverem mecanismos pra inibir o poder das tools, e permitir conjuntos mais restritos de mudanças entre essas interações.

Tudo isso é válido, mas e se houvesse uma forma do próprio cliente reverter essa operação?

Com esse cenário em mente, fiz alguns experimentos e gostaria de compartilhar com vocês uma versão simplificada pra ilustrar como poderíamos mitigar esse tipo de problema.

Considerando que estamos falando da arquitetura Rest para ser a interface do nosso sistema, uma alternativa seria nos aproveitarmos da sua capacidade semântica, e para os recursos críticos (como no caso do catálogo de produtos do exemplo acima) disponibilizar o rollback do recurso.
Dessa forma, ao implementar um MCP server, por exemplo, a tool de operação teria seu par de rollback.

No exemplo simplificado que comentei, fiz uma API que possui a entidade User que contém email e name apenas, e permite salvar e editar um usuário, e a entidade Snapshot que possui entity, entity_id e data que cuidará de manter a versão anterior de uma entidade.

Veja como ficou a arquitetura do projeto de exemplo:

graph TB
    %% Client Layer
    subgraph CLIENT ["🌐 CLIENT LAYER"]
        HTTP["HTTP Requests<br/>GET /users<br/>POST /users<br/>PUT /users/:email<br/>POST /users/:email/rollback"]
    end

    %% Presentation Layer
    subgraph PRESENTATION ["📡 PRESENTATION LAYER"]
        APP["Express App<br/>(index.ts)"]
        ROUTES["Routes Handler<br/>(routes.ts)"]
    end

    %% Use Case Layer (Refactored)
    subgraph USECASE ["🎯 USE CASE LAYER"]
        USERSUSECASES["UsersUseCases Class<br/>• createUser()<br/>• updateUser()<br/>• listUsers()<br/>• rollbackUser()"]
        USECASEINSTANCE["Singleton Instance<br/>(use-case/users/index.ts)"]
    end

    %% Repository Layer
    subgraph REPOSITORY ["🗄️ REPOSITORY LAYER"]
        USERREPOINTF["UsersRepository<br/>Interface"]
        USERREPO["UsersRepositoryInMemory<br/>(Implementation)"]
        SNAPREPO["SnapshotRepositoryInMemory<br/>(Implements SnapshotRepository)"]
    end

    %% Entity Layer
    subgraph ENTITY ["📊 ENTITY LAYER"]
        USER["User Entity<br/>{name, email}"]
        SNAPSHOT["Snapshot Entity<br/>{entity_id, entity, data}"]
    end

    %% Data Storage
    subgraph STORAGE ["💾 DATA STORAGE"]
        USERMAP["In-Memory Map<br/>User Storage<br/>+ Duplicate Prevention"]
        SNAPMAP["In-Memory Map<br/>Snapshot Storage"]
    end

    %% Communication Flow
    HTTP --> APP
    APP --> ROUTES
    
    ROUTES --> USECASEINSTANCE
    USECASEINSTANCE --> USERSUSECASES
    
    USERSUSECASES --> USERREPOINTF
    USERREPOINTF --> USERREPO

    USERREPO --> SNAPREPO
    USERREPO --> USER
    SNAPREPO --> SNAPSHOT

    USERREPO --> USERMAP
    SNAPREPO --> SNAPMAP

    %% Dependency Injection
    USECASEINSTANCE -.->|"Dependency Injection"| USERREPO

    %% Enhanced Features
    USERREPO -.->|"Duplicate Check"| USERMAP

    %% Styling for high contrast
    classDef clientStyle fill:#000000,stroke:#ffffff,stroke-width:3px,color:#ffffff
    classDef presentationStyle fill:#2d3748,stroke:#ffffff,stroke-width:2px,color:#ffffff
    classDef usecaseStyle fill:#4a5568,stroke:#ffffff,stroke-width:2px,color:#ffffff
    classDef repositoryStyle fill:#718096,stroke:#000000,stroke-width:2px,color:#000000
    classDef entityStyle fill:#e2e8f0,stroke:#000000,stroke-width:2px,color:#000000
    classDef storageStyle fill:#ffffff,stroke:#000000,stroke-width:3px,color:#000000

    class HTTP clientStyle
    class APP,ROUTES presentationStyle
    class USERSUSECASES,USECASEINSTANCE usecaseStyle
    class USERREPOINTF,USERREPO,SNAPREPO repositoryStyle
    class USER,SNAPSHOT entityStyle
    class USERMAP,SNAPMAP storageStyle

Se observar, o mecanismo de snapshot é incorporado ao mecanismo de manipulação de dados (na camada de armazenamento) e ativamente é invocado para "fotografar" o estado anterior da informação. Se olhar o código mais de perto, verá como isso foi feito:

class UsersRepositoryInMemory implements UsersRepository {
  private users: Map<string, User> = new Map();
  private snapshotRepository: SnapshotRepository = new SnapshotRepositoryInMemoryImpl();
  private _ENTITY = 'user';

  list(): User[] {
    logger.info({users:Array.from(this.users.values())},`Listing users`);
    return Array.from(this.users.values());
  }

  update(user: User): void {
    const previousState = this.users.get(user.email);

    logger.info({user},`Updating user`);

    this.snapshotRepository.store({
      entity_id: user.email,
      entity: this._ENTITY,
      data: JSON.stringify(previousState)
    });

    this.users.set(user.email, user);
  }

  store(user: User): void {
    if (!this.users.get(user.email)) {
      logger.info({user},`Storing user`);
      this.users.set(user.email, user);
    }
  }

  rollback(email: string): void {
    const previousState = this.snapshotRepository.findByEntity(this._ENTITY, email);
    if (previousState) {
      logger.info({previousState},`Rolling back user`);
      this.users.set(email, JSON.parse(previousState.data));
      this.snapshotRepository.delete(previousState.entity_id);
    }
  }

}

Neste projeto de exemplo, implementei o armazenamento em memória para ambas as entidades, mas em um cenário real, as entidades relacionadas ao negócio costumam ser armazenadas em bancos relacionais (seguindo ainda a proposição de ser um marketplace), e a entidade de Snapshot acredito que faria sentido ser armazenada em um sistema de armazenamento mais versátil, como por exemplo, um banco baseado em documentos.

Acredito ainda que em um cenário real, o ideal é utilizar um sistema de eventos para a criação dos snapshots, e manter o processamento da regra de negócio o mais leve possível (considerando alta escala). É claro, tudo depende seu caso de uso.

Porém, a ideia persiste. Toda vez que um recurso for atualizado, tiramos uma "fotografia" do estado antes da atualização e mantemos ela por um período de tempo para permitir a reversão ao estado anterior.

Isso vai garantir que se executarmos a seguinte sequência de operações:

Criar o usuário José

curl -H 'Content-Type: application/json' -d '{"name":"José", "email":"[email protected]"}' -X POST http://localhost:8000/users

Editar o nome do usuário José

curl -H 'Content-Type: application/json' -d '{"name":"José Filho", "email":"[email protected]"}' -X PUT http://localhost:8000/users/[email protected]

Executar o rollback

curl -H 'Content-Type: application/json' -X POST http://localhost:8000/users/[email protected]/rollback

Listar os usuários

curl http://localhost:8000/users

Teremos como resultado: [{"name":"José","email":"[email protected]"}].

Você já teve que lidar com uma situação similar? Se sim, como você resolveu esse tipo de problema?

Um forte abraço e bons estudos!

Carregando publicação patrocinada...
1

Meus 2 cents,

Entendi a ideia, mas o buraco eh um pouco mais embaixo.

Quando falamos de "rollback" precisa considerar todo o contexto de entidades interligadas - voltar apenas 1 entidade e presumir que "esta tudo bem" nao eh uma boa estrategia.

Imagine que o cliente fez a besteira, outras acoes foram efetuadas (como pedidos, nf, reembolsos, etc) e voce faz o "rollback" apenas de 1 entidade (p.ex. cliente) e isso causa um descompasso nas demais - acaba virando um pesadelo para entender o que esta correto e o que nao esta.

Ja vi esse cenario via "updates" com "wheres" mal feitos.

Nao existe saida facil aqui - trazer o backup mais recente ou ultimo snapshot do banco quase sempre implica em perder transacoes que precisam ser reconciliadas manualmente, e isso da um trabalho insano.

Uma possibilidade de atualizacao na tua proposta seria o mapeamento de dados e entidades que teriam reflexo na atualizacao e fazer snapshot delas tambem, e assim por diante - mas nao vejo isso como uma saida pratica/viavel.

1

Depois da nossa conversa inicial, decidi atualizar o exemplo pra ficar mais fácil de visualizar a ideia enquanto um mecanismo de compensação a nível de negócio, ao invés de estar associado à camada de dados. Isso porque, na camada de dados, a reconciliação vai certamente cair nas complicações que você muito bem colocou. Já na camada de negócios, seria o mesmo que delegar uma atualização em alto nível.
Não resolve todos os casos, mas pode ser o suficiente para alguns cenários.

graph TB
    %% Client Layer
    subgraph CLIENT ["🌐 CLIENT LAYER"]
        HTTP["HTTP Requests<br/>GET /health<br/>GET /users<br/>POST /users<br/>PUT /users/:email<br/>POST /users/:email/rollback"]
    end

    %% Presentation Layer
    subgraph PRESENTATION ["📡 PRESENTATION LAYER"]
        APP["Express App<br/>(index.ts)<br/>+ Pino Logger"]
        ROUTES["Routes Handler<br/>(routes.ts)"]
    end

    %% Use Case Layer (Refactored)
    subgraph USECASE ["🎯 USE CASE LAYER"]
        USERSUSECASES["UsersUseCases Class<br/>• createUser()<br/>• updateUser()<br/>• listUsers()<br/>• rollbackUser()"]
        SNAPSUSECASES["SnapshotsUseCases Class<br/>• createSnapshot()<br/>• getSnapshot()"]
        USECASEINSTANCE["Singleton Instance<br/>(use-case/users/index.ts)"]
    end

    %% Repository Layer
    subgraph REPOSITORY ["🗄️ REPOSITORY LAYER"]
        USERREPOINTF["UsersRepository<br/>Interface"]
        USERREPO["UsersRepositoryInMemory<br/>(Implementation)"]
        SNAPREPOINTF["SnapshotRepository<br/>Interface"]
        SNAPREPO["SnapshotRepositoryInMemoryImpl<br/>(Implementation)"]
    end

    %% Entity Layer
    subgraph ENTITY ["📊 ENTITY LAYER"]
        USER["User Interface<br/>{name: string, email: string}"]
        SNAPSHOT["Snapshot Interface<br/>{entity_id: string, entity: string, data: string}"]
    end

    %% Data Storage
    subgraph STORAGE ["💾 DATA STORAGE"]
        USERMAP["In-Memory Map<string, User><br/>User Storage<br/>+ Duplicate Prevention"]
        SNAPMAP["In-Memory Map<string, Snapshot><br/>Snapshot Storage<br/>Key: 'entity@entity_id'"]
    end

    %% Communication Flow
    HTTP --> APP
    APP --> ROUTES
    
    ROUTES --> USECASEINSTANCE
    USECASEINSTANCE --> USERSUSECASES
    
    USERSUSECASES --> USERREPOINTF
    USERREPOINTF --> USERREPO
    
    USERSUSECASES --> SNAPSUSECASES
    SNAPSUSECASES --> SNAPREPOINTF
    SNAPREPOINTF --> SNAPREPO

    USERREPO --> USER
    SNAPREPO --> SNAPSHOT

    USERREPO --> USERMAP
    SNAPREPO --> SNAPMAP

    %% Dependency Injection
    USECASEINSTANCE -.->|"Dependency Injection"| USERREPO
    USERSUSECASES -.->|"Composition"| SNAPSUSECASES
    SNAPSUSECASES -.->|"Dependency Injection"| SNAPREPO

    %% Enhanced Features
    USERREPO -.->|"Duplicate Check"| USERMAP
    SNAPREPO -.->|"Composite Key Storage"| SNAPMAP

    %% Styling for high contrast
    classDef clientStyle fill:#000000,stroke:#ffffff,stroke-width:3px,color:#ffffff
    classDef presentationStyle fill:#2d3748,stroke:#ffffff,stroke-width:2px,color:#ffffff
    classDef usecaseStyle fill:#4a5568,stroke:#ffffff,stroke-width:2px,color:#ffffff
    classDef repositoryStyle fill:#718096,stroke:#000000,stroke-width:2px,color:#000000
    classDef entityStyle fill:#e2e8f0,stroke:#000000,stroke-width:2px,color:#000000
    classDef storageStyle fill:#ffffff,stroke:#000000,stroke-width:3px,color:#000000

    class HTTP clientStyle
    class APP,ROUTES presentationStyle
    class USERSUSECASES,SNAPSUSECASES,USECASEINSTANCE usecaseStyle
    class USERREPOINTF,USERREPO,SNAPREPOINTF,SNAPREPO repositoryStyle
    class USER,SNAPSHOT entityStyle
    class USERMAP,SNAPMAP storageStyle

No código ficou assim:

class UsersUseCases {
  private snapshotsUseCases: SnapshotsUseCases;
  private _ENTITY = 'user';

  constructor(private readonly usersRepository: UsersRepository) {
    this.snapshotsUseCases = new SnapshotsUseCases(new SnapshotRepositoryInMemoryImpl());
  }

  createUser(user: User) {
    this.usersRepository.store(user);
  }

  updateUser(user: User) {
    const currentState = this.usersRepository.findByEmail(user.email);

    if (currentState) {
      this.snapshotsUseCases.createSnapshot({
        entity: this._ENTITY,
        entity_id: user.email,
        data: JSON.stringify(currentState)
      });
    }

    this.usersRepository.update(user);
  }

  listUsers() {
    return this.usersRepository.list();
  }

  rollbackUser(email: string) {
    const previousState = this.snapshotsUseCases.getSnapshot(this._ENTITY, email);
    if (previousState) {
      const user = JSON.parse(previousState.data) as User;
      this.updateUser(user);
    }
  }
}
1

Você trouxe bons pontos. E dada a proposta simplificada que usei pra apresentar a ideia, realmente, fica inviável aplicar em cenários reais.
Acredito que falhei em não adicionar a este contexto qual foi a inspiração, e olhando agora, acho que dava pra fazer uma implementação mais completa sem perder a simplicidade.

Mas a ideia da abstração foi "chupinhada" da abordagem de compensação de transações que o "Saga Pattern" implementa para ambientes distribuídos, aonde cada etapa da transação possui um mecanismo de compensação para que, em eventual falha na cadeia de um workflow distribuído, seja possível a reversão da transação.

Como ali, injetei o snapshot na camada de gestão de dados fica ainda mais complicado de associar com a ideia que mencionei acima.

Mas usando este comentário como oportunidade pra acrescentar um pouco mais ao texto, Eu diria que o ideal é que em camadas superiores, um workflow possa permitir tirar uma "fotografia" do estado atual das entidades relacionadas à operação, e ao gravar isso, possamos retroceder se necessário.
Isso faria da compensação, em linhas bem gerais, uma operação de atualização que reutiliza os dados anteriores à última edição.

Por exemplo, no caso do código que coloquei de exemplo, o correto seria mover a gestão do snapshot para a camada de negócios (casos de uso).

Bons estudos e um forte abraço!

1

Meus 2 cents extendidos,

Refletindo mais um pouco - tem outro aspecto que tua publicacao traz a tona.

Antigamente, o usuario final tinha poucas possibilidades de alterar em lote grandes quantidades de dados de modo nao-supervisionado: para fazer este tipo de alteracao ou era atraves de alguma opcao do sistema (que tinha algum tipo de salvaguarda, como p.ex. permitir alteracoes em campos nao criticos) ou entao pedir para o TI fazer a alteracao (garantindo assim um minimo de analise da tarefa).

Quando a IA comeca a ter acesso a endpoints diretamente, e por consequencia, permitir ao usuario criar suas automacoes que podem afetar dezenas/milhares de dados em uma unica acao - talvez tenhamos de repensar metodologias de ACID/commit/transaction que tambem consideram este tipo de cenario.

Como foi comentado - snapshots poderiam ser um caminho, mas manter a integridade das regras de negocio em situacoes de rollback eh algo bem complicado: afinal, se um cliente recebeu uma notificacao de pedido aceito e faturamento realizado - como lidar com um rollback em algumas das entidades envolvidas ?

1

Perfeito. Bem colocado.
Para cenários aonde o impacto do rollback não envolve apenas entidades internas, de fato, não há uma resposta simples e talvez, a abordagem de compensação seja apenas a ponta de um iceberg, que certamente, permite muita exploração para melhorias futuras.
Acredito que esse tipo de padrão de compensação de transações vai se tornar cada vez mais comum, inclusive, a implantação de um 2 phase commit quando lidarmos com operações críticas.
Acredito muito que a discussão desse tipo de garantia de "consistência intencional" - com "intencional", estou tentando traduzir a ideia de que a consistência precisa estar atrelada à intenção de alterar o estado de uma entidade e mantê-la, na falta de uma expressão mais precisa - será cada vez mais frequente.
E falo isso com a propriedade de alguém que tem participado ativamente deste tipo de conversa, e enfrentado problemas desta natureza no dia-a-dia.

Um forte abraço!