Operações atômicas com Deno KV

No outro artigo comentamos sobre a nova versão beta fechada do Deno KV e também falamos sobre o conceito de chave e valor. Mas, infelizmente, como o artigo já estava meio grande, algumas coisas ficaram de fora, uma dessas coisas foi a incrível capacidade de atomicidade do KV.

Mas o que é ser atômico? E o que isso significa no contexto de um banco de dados desse porte?

Atomicidade

Atomicidade é a letra A do acrônimo ACID que você provavelmente já ouviu, cada uma das letras do ACID tem um significado profundo para qualquer banco de dados, especialmente se ele suportar transações:

  • Atomicidade: Que é a mais importante pra gente aqui agora. Esse nada mais é do que o conceito de transações. Ou seja, um conjunto de operações que são executados de uma única vez como se fossem um único átomo, ou tudo acontece, ou nada acontece.
  • Consistência: Garantia de que as transações no banco só vão modificar as tabelas de forma pré-definida. Além disso, essa propriedade também garante que qualquer tipo de corrupção de dados ou perda em um determinado lugar não vai afetar outras tabelas.
  • Isolamento: Essencialmente trata o problema de corrida, quando múltiplos usuários estão realizando leituras e escritas no banco de dados, o isolamento das transações garante que essas transações concorrentes não interfiram umas com as outras.
  • Durabilidade: É a principal propriedade de um banco de dados. Ela garante que qualquer mudança em seus dados através de qualquer transação de sucesso vão ser persistidas mesmo no evento de uma falha de sistema.

Além de operações não atômicas, o KV também suporta transações atômicas, onde ou tudo é executado ou nada é executado. Esse conceito é baseado em mutations que são um conjunto de ações aplicados a um registro.

Mais uma vez o KV usa as versionstamps para poder saber o que foi modificado ou não, a transação só será enviada com sucesso se as versionstamps atuais das chaves forem as mesmas das que foram passadas na mutação, dessa forma é possível garantir que estamos modificando a última versão dos dados.

Nestas operações estão inclusas todas as anteriores com algumas diferenças:

  • check: equivalente ao get, porém ao invés de obter uma chave, vai testar uma chave já obtida contra o versionstamp que está no banco de dados
  • sum: um tipo de operação mutate, mas sem atalho direto
  • min: outro mutate, mas que tem um atalho direto
  • max: outro mutate, também um atalho direto
  • commit: finaliza a transação e envia os valores ao banco
  • delete: igual ao anterior

Para esta explicação é melhor usar um exemplo do que passar método por método, já que a maioria das funcionalidades são conhecidas. O próprio exemplo do Deno é muito bom porque é uma das principais funcionalidades quando estamos trabalhando com transações: transferência de dinheiro.

Quando estamos transferindo fundos de uma conta para a outra, primeiro temos que garantir que a primeira conta tem os fundos, se fizermos de forma assíncrona, é possível que, enquanto estamos tentando transferir o dinheiro de uma conta para a outra, uma transação aconteça no meio e não tenhamos mais os fundos necessários, por isso precisamos executar todas as operações (tirar de uma conta e colocar em outra) de uma vez só, ou não executar nenhuma.

const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
cosnt amount = 100

// tentamos a transação até conseguir
let res = { ok: false }

while (!res.ok) {
	const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
    
    if (!senderResponse || !receiverResponse) break
    
    const senderBalance = senderRes.value
    const receiverBalance = receiverRes.value
    
    if (senderBalance < amount) {
    	throw new Error('Saldo insuficiente')
    }
    
    const newSenderBalance = senderBalance - amount
    const newReceiverBalance = receiverBalance + amount
 	
    // salvamos no banco de dados
}

Até aqui estamos fazendo tudo em memória. Para salvarmos no banco precisamos primeiro checar os dois saldos, podemos fazer isso com o comando check:

const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
const amount = 100

// tentamos a transação até conseguir
let res = { ok: false }

while (!res.ok) {
	const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
    
    if (!senderResponse || !receiverResponse) break
    
    const senderBalance = senderRes.value
    const receiverBalance = receiverRes.value
    
    if (senderBalance < amount) {
    	throw new Error('Saldo insuficiente')
    }
    
    const newSenderBalance = senderBalance - amount
    const newReceiverBalance = receiverBalance + amount
 	
    // salvamos no banco de dados
    res = await kv.atomic()
    	.check(senderResponse)
        .check(receiverResponse)
}

Aqui é importante notar duas coisas:

  1. Estamos usando o método atomic(), que é o namespace que vai ter todas as propriedades atômicas do KV
  2. Estamos usando o check e passando como parâmetro não um valor, mas a resposta inteira do comando getMany, porque precisamos passar tanto o valor quanto a versionstamp daquela chave

O que o check vai fazer é realizer um get no KV e verificar se os dois registros são idênticos, se falhar, então a transação será abortada. Agora podemos atualizar os valores de cada pessoa:

const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
const amount = 100

// tentamos a transação até conseguir
let res = { ok: false }

while (!res.ok) {
	const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
    
    if (!senderResponse || !receiverResponse) break
    
    const senderBalance = senderRes.value
    const receiverBalance = receiverRes.value
    
    if (senderBalance < amount) {
    	throw new Error('Saldo insuficiente')
    }
    
    const newSenderBalance = senderBalance - amount
    const newReceiverBalance = receiverBalance + amount
 	
    // salvamos no banco de dados
    res = await kv.atomic()
    	.check(senderResponse)
        .check(receiverResponse)
        .set(senderKey, newSenderBalance)
        .set(receiverKey, newReceiverBalance)
        .commit()
}

Toda transação deve ser finalizada com um commit() para que possamos executar a fila de operações que foram feitas.

Agora que a gente entendeu o conceito, vamos ver as outras operações.

kv.atomic().mutate() - Sum, Min e Max

Além das operações normais, temos um outro método chamado mutate dentro de atomic(), este método aceita um objeto de configuração que pode ter três chaves:

  • type: O tipo da mutação, que pode ser, até o momento, sum, min ou max
  • key: A chave a ser modificada
  • value: O novo valor, precisa ser um objeto do tipo Deno.KvU64 que é criado a partir de um BigInt com new Deno.KvU64(100n) por exemplo

Vamos falar sobre as mutações no geral, vou dar o primeiro exemplo com o sum, mas não há muito a necessidade de explicar as demais com exemplos porque elas seguem a mesma ideia:

Sum

O sum vai atomicamente adicionar um valor a uma chave. Se o valor não existir, ele vai ser criado com o valor que seria adicionado, por exemplo, se adicionarmos 10 em uma chave não existente, o resultado vai ser a chave que queremos adicionar, com o valor 10. Se a chave existir, o valor vai ser adicionado através de uma soma.

Operações de mutation só podem ser feitas em tipos de dados BigInt que, no Deno KV, são representadas pelo tipo Deno.KvU64, que significa Deno KV Unsigned 64-bit Integer, esse tipo não pode ser armazenado em nenhuma estrutura, ele precisa ser um top-level value.

A estrutura básica de uma soma é a seguinte:

await kv.atomic()
	.mutate({
    	type: 'sum',
        key: ['accounts', 'alice'],
        value: new Deno.KvU64(80n),
    })
    .commit()

Isso significa que podemos substituir nosso código acima por algo como:

const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
const amount = 100

// tentamos a transação até conseguir
let res = { ok: false }

while (!res.ok) {
	const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
    
    if (!senderResponse || !receiverResponse) break
    
    const senderBalance = senderRes.value
    const receiverBalance = receiverRes.value
    
    if (senderBalance < amount) {
    	throw new Error('Saldo insuficiente')
    }
 	
    // salvamos no banco de dados
    res = await kv.atomic()
    	.check(senderResponse)
        .check(receiverResponse)
        .mutate({
        	type: 'sum',
            key: senderKey,
            value: new Deno.KvU64(-BigInt(amount)),
        })
        .mutate({
        	type: 'sum',
            key: receiverKey,
            value: new Deno.KvU64(BigInt(amount)),
        })
        .commit()
}

Min e Max

Da mesma forma que o sum, o min e maxsetados como typevão fazer com que a chave obtenha o menor e maior valor, respectivamente, comparado entre o valor atual da chave e o valor que você está passando.

Por exemplo, se tivermos uma chave ['accounts', 'alice'] cujo valor é 100, e passarmos uma mutation do tipo min com o valor de 50, o novo valor vai ser Math.min(100, 50) que é 50, porém se o valor original for 30, a chave não será modificada. O mesmo vale para o max.

Da mesma forma que o sum, se a chave não existir ela será criada com o valor passado, ou seja, não será assumido que o valor é 0 inicialmente para nenhum dos casos.

await kv.atomic()
	.mutate({
    	type: 'min',
        key: ['accounts', 'alice'],
        value: new Deno.KvU64(100n),
    })
    .commit()

Conclusão

Com as operações atômicas, podemos realizar transações mais seguras que são garantidas de terem o resultado esperado.

Nos próximos artigos vamos construir algum projeto usando o Deno Deploy e o Deno KV!