Skip to main content

Write Safely to Files

Thiago Lages de Alencar

Ano passado decidi explorar como garantir escrever em um arquivo sem que outros processos atrapalhem. Isto levou a criação do post: Lock Files Process.

Agora o problema que tenho refletido é sobre como editar arquivos de forma segura.

Problem 1

Se eu possuo apenas um arquivo, eu posso utilizar locks do sistema operacional para evitar que outros processos leiam/escrevam enquanto eu estou escrevendo.

process_1
└── file_a.json (get lock)
process_2
└── file_a.json (wait lock)
note

Isso não impede que processos má intencionados corrompam seu arquivo, pois eles ainda podem ignorar estas locks.

Mas o que acontece se tivermos no meio de escrever um arquivo grande e o computador desligar/travar? Existe o risco do nosso arquivo ficar escrito pela metade...

Problem 2

Vamos supor que temos a solução do problema acima, como solucionamos para casos onde um comando da CLI precisa alterar diversos arquivo e o computador desliga/trava no meio?

process_1
├── file_a.json (editing)
├── file_b.json (editing)
└── file_c.json (editing)

Isto quer dizer que pode travar após terminar de editar um arquivo enquanto os outros não:

process_1
├── file_a.json (edited)
├── file_b.json (editing)
└── file_c.json (editing)

Ler do arquivo file_a.json poderia nos dar a ilusão que o comando da CLI funcionou quando na realidade nem tudo foi aplicado.

Problem 3

O outro problema que tenho refletido é quando múltiplos processos precisam alterar múltiplos arquivos ao mesmo tempo.

process_1 precisa utilizar file_a.json e file_b.json
process_2 precisa utilizar file_a.json e file_b.json

process_1
├── file_a.json (get lock)
└── file_b.json (get lock)
process_2
├── file_a.json (wait lock)
└── file_b.json

Neste caso nenhum problema ocorre pois process_2 vai ficar esperando process_1 liberar a lock de file_a.json para continuar com suas tarefas. Isso é o mesmo que executar os processos sequencialmente.

process_1 precisa utilizar file_a.json e file_b.json
process_2 precisa utilizar file_b.json e file_c.json

process_1
└── file_a.json (get lock)
process_2
└── file_b.json (get lock)
process_1
└── file_b.json (wait lock)
process_2
└── file_c.json (get lock)

Neste caso, mesmo process_1 começando primeiro, ele vai ter que esperar process_2 liberar a lock que foi pega no meio da execução dele. Isso também não é um caso ruim, pois no final a lock deve ser liberada.

process_1 precisa utilizar file_a.json e file_b.json
process_2 precisa utilizar file_b.json e file_a.json

process_1
└── file_a.json (get lock)
process_2
└── file_b.json (get lock)
process_1
└── file_b.json (wait lock)
process_2
└── file_a.json (wait lock)

É neste caso em que um deadlock ocorre. Onde ambos processos ficam esperando um pelo o outro e podem nunca sair do loop.

Mesmo que eu considere qualquer timeout que os processos tenham, nada impede de acontecer novamente e novamente... Então não é ideal só esperar que não aconteça.

Solution 1

Uma maneira de solucionar isto é escrever em arquivos temporários e no final substituir os originais.

process_1
├── file_a.json
└── file_a.temp

Veja bem, enquanto você escreve nos arquivos temporários, os arquivos originais vão ficar intactos e sem risco de serem corrompidos em caso de falhas no sistema. Quando finalmente você terminar de escrever o que queria neles, você substitui os originais utilizando uma operação atómica (mv ou rename).

Basicamente a lógica é:

  • Dado que temos o arquivo original (xxxx.json)
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • Substitua o arquivo original pelo temporário

Se considerarmos que temos locks:

  • Dado que temos o arquivo original (xxxx.json)
  • Pegue a lock do arquivo original
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • Substitua o arquivo original pelo temporário
  • Solte a lock do arquivo original
warning

Ambas operações mv e rename são atómicas quando se trata do mesmo volume (HD/SSD) pois é só um redirecionamento do ponteiro. Se estivermos falando dessas operações entre volumes diferentes, seria preciso que um volume copiasse para o outro volume antes e isso não é atómico.

Porém como estamos lidando com locks, e é necessário saber se podemos renomear um arquivo com lock... A resposta é sim para Linux/Macos e não para Windows.

Isso ocorre pois Windows utiliza mandatory lock (em vez de advisory lock), isso obriga todos os processos a respeitarem o acesso aquele arquivo e proibi a remoção do arquivo enquanto possuir uma lock.

Solution 1+

Podemos contornar esse problema utilizando um arquivo de acesso como lock.

process_1
├── file_a.json
├── file_a.lock
└── file_a.temp

Agora nos criamos a regra interna que apenas aqueles com a lock do arquivo de acesso podem escrever no arquivo.

  • Dado que temos o arquivo original (xxxx.json)
  • Pegue a lock do arquivo de acesso (xxxx.lock)
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • Substitua o arquivo original pelo temporário
  • Solte a lock do arquivo de acesso

Solution 1++

Existe um pequeno detalhe que eu esqueci de cobrir... Enquanto operações as operações rename e mv são atómicas na memória (RAM), isso não quer dizer que sua mudanças já foram para o storage (HD/SSD).

CPU trabalha com a memória e de tempos em tempos armazena as alterações no storage, ou seja, a gente chama rename e todos os processos já vão poder ver isso como verdade... Mas não quer dizer que as mudanças já foram salvas no storage.

Por isto que precisamos utilizar comandos como fsync que forção o flush dos dados no nosso storage.

  • Dado que temos o arquivo original (xxxx.json)
  • Pegue a lock do arquivo de acesso (xxxx.lock)
  • Copie o conteúdo do arquivo original para o temporário (xxxx.temp)
  • Escreva sempre no arquivo temporário
  • fsync no arquivo temporário
  • Substitua o arquivo original pelo temporário
  • fsync no diretório do arquivo original
  • Solte a lock do arquivo de acesso

O primeiro fsync garante que o arquivo temporário esteja storage antes de substituir o original.

danger

Imagina se a gente usasse rename, sem ele ter sido armazenado no storage!

Você apenas está substituindo os ponteiros quando faz um rename, então é bom você ter certeza que o ponteiro seja para um espaço do drive com conteúdo (caso contrário você está apontando para lixo).

O segundo fsync atualiza o metadata do diretório (os arquivos que ele possue).

Solution 2

Para garantir a atomicidade dessa alteração em conjunto, podemos criar um journal que irá armazenar todos os arquivos que precisam ser substituidos e seus estados.

Iremos definir 3 estados para nossos arquivos temporários:

  • WRITE
    • Processo pode estar copiando o arquivo original
    • Processo pode estar alterando o arquivo temporário
  • REPLACE
    • Processo não vai fazer mais alterações no arquivo temporário
    • Processo está substituindo o arquivo original pelo temporário
  • DELETE
    • Processo já substituiu o arquivo original
    • Processo está deletando o arquivo temporário

Os arquivos temporário caminham pelos estados na ordem WRITE, REPLACE, DELETE.

process_1
└── journal
├── file_a.temp (WRITE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)

Encontrar um arquivo no estado WRITE indica que o processo nunca terminou de escrever os temporários, então você pode descartar todos os arquivos temporários pois não é possível continuar de onde a tarefa parou.

process_1
└── journal
├── file_a.temp (REPLACE)
├── file_b.temp (REPLACE)
└── file_c.temp (REPLACE)

Encontrar um arquivo no estado REPLACE indica que é possível terminar a tarefa pois o arquivo temporário já terminou de ser alterado, basta você terminar de substituir o arquivo.

process_1
└── journal
├── file_a.temp (DELETE)
├── file_b.temp (DELETE)
└── file_c.temp (DELETE)

Encontrar um arquivo no estado DELETE indica que ele precisa ser removido pois já foi utilizado como deveria.

É importante notar que todos os arquivos devem caminhar em conjunto pelas etapas. Por exemplo:

process_1
└── journal
├── file_a.temp (REPLACE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)

O arquivo file_a.temp não pode ir para o estado DELETE até que todos os outros arquivos estejam no mesmo estado que ele (REPLACE).

A ausência dessa regra pode criar cenários impossíveis de se recuperar após uma falha no sistema. Por exemplo:

process_1
└── journal
├── file_a.temp (DELETE)
├── file_b.temp (WRITE)
└── file_c.temp (WRITE)
  • Não temos como reverter a alteração no file_a.temp pois já substituimos o arquivo
  • Não podemos completar de escrever o conteúdo de file_b.temp e file_c.temp pois não sabemos o que estava sendo escrito

O próximo problema que temos é... Onde armazenar esse journal? Outro processos vão precisar ver ele para saber se precisam terminar alguma operação passada.

Solution 2+

Podemos armazenar o journal de cada processo em um arquivo dentro de um diretório qualquer (iremos chamar o diretório de .journals).

.journals
├── <pid_0001>.journal
├── <pid_0002>.journal
└── <pid_0003>.journal

Utilizamos o PID de cada processo como nome dos arquivos (pois eles são únicos).

Agora precisamos garantir o tratamento de qualquer journal incompleto, para isto vamos definir regras que todo processo deve executar antes de começar sua própria tarefa:

  • Verifica se <pid>.journal
    • Se existir, nós sabemos que foi um processo antigo que não finalizou então começamos o tratamento
  • Verifica se outro jornal incompleto existe
    • Se existir, nós iniciamos o tratamento daquele journal

Como sabemos que PIDs são únicos é fácil saber que um journal com o nosso PID é um journal incompleto. Mas como sabemos se outros journals são incompletos ou possuem alguém processando?

Solution 2++

Podemos utilizar locks para informar a outro processos que este journal está em tratamento.

.journals
├── <pid_0001>.journal (write lock)
├── <pid_0002>.journal (write lock)
└── <pid_0003>.journal (no lock)

Se o processo encontrar um journal sem lock, nós teremos certeza que ninguém está tratando ele no momento e devemos pegar o lock para nós e terminar o journal.

Solution 3

Para garantir que não exista deadlock, precisamos garantir que um processo pegue suas locks antes de outro processo tentar pegar as deles.

Para isto criaremos um arquivo que chamaremos de .global.lock, o qual todos os processos devem tentar pegar a lock antes de iniciar as tarefas.

  • Se nosso processo não conseguir a lock do .global.lock
    • Existe um processo pegando locks, devemos aguardar
  • Caso contrário
    • Nós podemos pegar as locks que são necessárias para nossa tarefa

É importante que essa lock seja liberada após aquele processo pegar as locks que precisa (ou falhar em pegar), caso contrário diversos processos podem acabar esperando pela liberação da .global.lock.

Conclusion

Eu não comecei a escrever nada de código ainda mas posso garantir uma coisa... Vai ser lento.

  • Clonar o arquivo original
  • Read & Write no journal o tempo todo
  • Lock & Unlock arquivos o tempo todo

Tudo é receita para ser bem lento, não é atoa que SQLite faz tudo em um arquivo e trabalha maior parte na memória RAM.

Dito isto, isto vai ser um projeto interessante para mim.

Rosto feliz

References