Introdução
Arakoon tenta ser uma solução simples de armazenamento distribuído baseado em chave valor que favorece a consistência em relação à disponibilidade. De tempos em tempos, temos solicitações de recursos para comandos adicionais, tais como:
- assert_exists: afirma existir um valor para a chave sem se importar com o que o valor realmente é
- increment_counter: incrementa (ou cria) um contador e retorna o novo valor
- queue operations: adicionam um elemento para a frente/trás para o final de uma fila dupla ou retira um elemento
- set_and_update_X: nsere alguns pares de chave valor e atualiza alguns contadores X não triviais (pense em médias, variações, …)
- …
A lista é quase infinita, e o elemento comum aqui é que elas são muito complexas/específicas/estranhas/… para fazê-las em uma única etapa usando a interface fornecida. Claro, você pode fazer tudo isso no lado do cliente, mas vai custar idas e voltas extras na rede. Em sistemas distribuídos, você quer realmente manter o número de ciclos baixo, que leva você para esse tipo de solicitações de recursos.
Uma vez que você decidiu (afunilamentos de desempenho provavelmente) que precisa de funcionalidade extra, existem duas coisas que você pode fazer. Primeiro, você pode tentar forçar ou nos induzir a adicioná-los à interface do núcleo ou, alternativamente, você pode obter usando “funções de usuário” do Arakoon. Por alguma razão, as pessoas as temem, mas não existe razão real técnica alguma para isso.
Este artigo vai abordar duas coisas. Primeiro, vamos entrar no ponto central de codificação e implementação de funções de usuário e então vamos examinar alguns dos desafios estratégicos/arquitetônicos de funções de usuário.
Como funcionam as funções de usuário?
A visão de alto nível é esta: você constrói uma função de usuário, e a registra em um cluster Arakoon antes de iniciá-la. Então, em tempo de execução, você pode chamá-la, usando qualquer cliente, com um parâmetro (uma opção de string) e receber de volta um resultado (opção de string). No lado do servidor, o mestre registrará isso em seu log de transações, para tentar chegar a um consenso com o slave (s) e, se for o caso, a função de usuário será executada dentro de uma transação. O resultado dessa chamada será enviado para o cliente. Se ocorrer uma exceção, a transação será abortada. A partir do momento em que o Arakoon faz log de todas as suas transações, ele pode repeti-las em caso de calamidades. Isso tem um impacto muito importante: uma vez que o Arakoon precisa ser capaz de repetir a execução das funções de usuários, elas não podem ter efeitos colaterais, usar valores randômicos ou ler o relógio do sistema.
Exemplo em execução
Vamos tentar construir uma API de queue simples.
Ela vai oferecer queues nomeadas com duas operações: push e pop. Além disso, é uma coisinha first-in-first-out (FIFO) – em português, primeiro a entrar, primeiro a sair..
Arakoon 1
API do lado do cliente
Arakoon 1 oferece a seguinte API para funções de usuário.
def userFunction(self, name, argument): '''Call a user-defined function on the server @param name: Name of user function @type name: string @param argument: Optional function argument @type argument: string option @return: Function result @rtype: string option '''
Vamos dar uma olhada. Uma chamada userFunction precisa do nome, que é uma string, e de um argumento, que é uma opção de string e retorna um resultado de opção tipo string. Então, o que exatamente é uma opção de string em Python? Bem, é uma string ou None. Isso permite que uma função de usuário não leve a entrada ou a não produzir um resultado.
API do lado do servidor
A API do lado do servidor é em OCaml, e fica assim:
class type user_db = object method set : string -> string -> unit method get : string -> string method delete: string -> unit method test_and_set: string -> string option -> string option -> string option method range_entries: string option -> bool -> string option -> bool -> int -> (string * string) list end
Funções de usuário no lado do servidor correspondem à assinatura opaca do cliente.
user_db -> string option -> string option
Fila do lado do cliente
Vamos criar o lado do cliente em Python. Nós vamos criar uma classe que usa um cliente Arakoon e atua como uma fila. O problema com o push é que precisamos ajustar tanto o nome quanto o valor para o parêmetro que temos disponível. Precisamos fazer a nossa própria serialização. Vamos ser preguiçosos (inteligentes?) e usar a serialização do Arakoon. O código é mostrado abaixo.
from arakoon import Arakoon from arakoon import ArakoonProtocol as P class ArakoonQueue: def __init__(self, name, client): self._name = name self._client = client def push(self, value): input = P._packString(self._name) input += P._packString(value) self._client.userFunction("QDemo.push", input) def pop(self): value = self._client.userFunction("QDemo.pop", self._name) return value
Isso não foi muito difícil, foi?
Fila do lado do servidor
A ideia é que as operações aconteçam no lado do servidor, de modo que isso será mais complexo.
Precisamos modelar uma fila usando um armazenamento de chave valor. Codifique sabiamente, o que não é muito difícil.
Para cada fila, vamos manter dois contadores que mantêm o controle de ambas as extremidades da fila.
Um push está simplesmente recebendo o qname e o value de entrada, o cálculo do lugar onde precisamos armazená-la; guarde o valor lá e atualize o contador para o final da fila. Um pop é semelhante, mas quando a fila fica vazia, usamos a oportunidade de zerar os contadores (maybe_reset_counters). A representação de contador é um pouco estranha, mas o Arakoon armazena coisas em ordem lexicográfica, e queremos aproveitar isso para manter nossa fila fifo (primeiro a sair, primeiro a entrar). Por isso, é necessário fazer o contador de tal maneira que a ordem dele seja igual à ordem de uma string. O código é mostrado abaixo.
(* file: plugin_qdemo.ml *) open Registry let zero = "" let begin_name qname = qname ^ "/@begin" let end_name qname = qname ^ "/@end" let qprefix qname key = qname ^ "/" ^ key let next_counter = function | "" -> "A" | s -> begin let length = String.length s in let last = length - 1 in let c = s.[last] in if c = 'H' then s ^ "A" else let () = s.[last] <- Char.chr(Char.code c + 1) in s end let log x= let k s = let s' = "[plugin_qdemo]:" ^ s in Lwt.ignore_result (Lwt_log.debug s') in Printf.ksprintf k x let maybe_reset_counters user_db qname b1 = let e_key = end_name qname in let exists = try let _ = user_db # get e_key in true with Not_found -> false in if exists then let ev = user_db # get e_key in if ev = b1 then let b_key = begin_name qname in let () = user_db # set b_key zero in let () = user_db # set e_key zero in () else () else () let push user_db vo = match vo with | None -> invalid_arg "push None" | Some v -> let qname, p1 = Llio.string_from v 0 in let value, _ = Llio.string_from v p1 in let e_key = end_name qname in let b0 = try user_db # get (end_name qname) with Not_found -> zero in let b1 = next_counter b0 in let () = user_db # set (qprefix qname b1) value in let () = user_db # set e_key b1 in None let pop user_db vo = match vo with | None -> invalid_arg "pop None" | Some qname -> let b_key = begin_name qname in let b0 = try user_db # get (begin_name qname) with Not_found -> zero in let b1 = next_counter b0 in try let k = qprefix qname b1 in let v = user_db # get k in let () = user_db # set b_key b1 in let () = user_db # delete k in let () = maybe_reset_counters user_db qname b1 in Some v with Not_found -> let e_key = end_name qname in let () = user_db # set b_key zero in let () = user_db # set e_key zero in None let () = Registry.register "QDemo.push" push let () = Registry.register "QDemo.pop" pop
As duas últimas linhas registram as funções para o cluster Arakoon quando o módulo é carregado.
Compilação
Então, como você implementa o módulo de função de usuário em um cluster Arakoon?
Em primeiro lugar, é necessário compilar seu módulo em algo que possa ser carregado dinamicamente.
Para compilar plugin_qdemo.ml, eu induzo o ocamlbuild assim:
ocamlbuild -use-ocamlfind -tag 'package(arakoon_client)' \ -cflag -thread -lflag -thread \ plugin_qdemo.cmxs
Não é muito difícil escrever seu próprio testcase para sua funcionalidade, para que você possa executá-lo fora do Arakoon e se concentrar em obter o código certo.
Deployment
Primeiro, você precisa colocar sua unidade de compilação no diretório home do Arakoon em todos os seus nodes do cluster. E, segundo, você precisa adicionar o nome à seção global da configuração do cluster. Abaixo, mostrarei o arquivo de configuração para o meu simples e único node do cluster node, chamado ricky.
[global] cluster = arakoon_0 cluster_id = ricky ### THIS REGISTERS THE USER FUNCTION: plugins = plugin_qdemo [arakoon_0] ip = 127.0.0.1 client_port = 4000 messaging_port = 4010 home = /tmp/arakoon/arakoon_0
Tudo bem, é isso. Só um aviso importante sobre as funções de usuário aqui.
Uma vez que a função de usuário é instalada, ela deve permanecer disponível, com a mesma funcionalidade, durante o tempo que as chamadas de funções de usuário levam para ser armazenadas dentro dos registros de transações, uma vez que elas precisam ser reavaliadas quando um repete um log de transações para um armazenamento (para exemplo, quando um node falhar, deixando um banco de dados corrompido para trás). Não é uma ideia ruim incluir uma versão no nome de uma função de usuário para atender à evolução.
Demo
Vamos usá-lo em um script simples de Python.
def make_client(): clusterId = 'ricky' config = Arakoon.ArakoonClientConfig(clusterId, {"arakoon_0":("127.0.0.1", 4000)}) client = Arakoon.ArakoonClient(config) return client if __name__ == '__main__': client = make_client() q = ArakoonQueue("qdemo", client) q.push("bla bla bla") q.push("some more bla") q.push("3") q.push("4") q.push("5") print q.pop() print q.pop() print q.pop() print q.pop()
com os resultados esperados.
Arakoon 2
Com o Arakoon 2, mudamos para o Baardskeerder como um backend de banco de dados, substituindo a combinação de logs de transações e Tokyo Cabinet. Uma vez que a infraestrutura é Lwt consciente, isso significa que a API do lado do servidor tornou-se também:
module UserDB : sig type tx = Core.BS.tx type k = string type v = string val set : tx -> k -> v -> unit Lwt.t val get : tx -> k -> (v, k) Baardskeerder.result Lwt.t val delete : tx -> k -> (unit, Baardskeerder.k) Baardskeerder.result Lwt.t end module Registry: sig type f = UserDB.tx -> string option -> (string option) Lwt.t val register: string -> f -> unit val lookup: string -> f end
As principais alterações são que
- A api agora usa LWT
- Temos tipos (‘a,’b) Baardskeerder.result , que favorecem mais o uso de exceções para casos normais.
Reescrever a implementação da fila para Arakoon 2 produz algo assim:
(* file: plugin_qdemo2.ml *) open Userdb open Lwt open Baardskeerder let zero = "" let begin_name qname = qname ^ "/@begin" let end_name qname = qname ^ "/@end" let qprefix qname key = qname ^ "/" ^ key let next_counter = function | "" -> "A" | s -> begin let length = String.length s in let last = length - 1 in let c = s.[last] in if c = 'H' then s ^ "A" else let () = s.[last] <- Char.chr(Char.code c + 1) in s end let reset_counters tx qname = let b_key = begin_name qname in let e_key = end_name qname in UserDB.set tx b_key zero >>= fun () -> UserDB.set tx e_key zero let maybe_reset_counters tx qname (b1:string) = let e_key = end_name qname in begin UserDB.get tx e_key >>= function | OK _ -> Lwt.return true | NOK _ -> Lwt.return false end >>= function | true -> begin UserDB.get tx e_key >>= function | OK ev -> if ev = b1 then reset_counters tx qname else Lwt.return () | NOK _ -> Lwt.return () end | false -> Lwt.return () let push tx vo = match vo with | None -> Lwt.fail (invalid_arg "push None") | Some v -> let qname, p1 = Llio.string_from v 0 in let value, _ = Llio.string_from v p1 in Lwt_log.debug_f "push:qname=%S;value=%S" qname value >>= fun ()-> let e_key = end_name qname in UserDB.get tx (end_name qname) >>= fun b0r -> let b0 = match b0r with | OK b0 -> b0 | _ -> zero in let b1 = next_counter b0 in UserDB.set tx (qprefix qname b1) value >>= fun () -> UserDB.set tx e_key b1 >>= fun () -> Lwt.return None let pop tx = function | None -> Lwt.fail (invalid_arg "pop None") | Some qname -> begin let b_key = begin_name qname in UserDB.get tx (begin_name qname) >>= fun b0r -> begin match b0r with | OK b0 -> Lwt.return b0 | NOK _ -> Lwt.return zero end >>= fun b0 -> let b1 = next_counter b0 in let k = qprefix qname b1 in UserDB.get tx k >>= fun vr -> begin match vr with | OK value -> begin UserDB.set tx b_key b1 >>= fun () -> UserDB.delete tx k >>= function | OK () -> begin maybe_reset_counters tx qname b1 >>= fun () -> Lwt.return (Some value) end | NOK e -> Lwt.fail (Failure e) end | NOK _ -> reset_counters tx qname >>= fun () -> Lwt.return None end end let () = Userdb.Registry.register "QDemo.push" push let () = Userdb.Registry.register "QDemo.pop" pop
Ambos lado do cliente e implementação permanecem os mesmos.
Perguntas feitas
Não tem algo de errado com esta fila?
Sim! Fico feliz que você tenha percebido. Esse conceito de fila está fundamentalmente quebrado. O problema é o pop.
Acompanhe este exemplo:
- o cliente chama a função QDemo.pop
- o cluster retira o valor da fila e o master envia essa informação para o cliente
- o cliente morre antes de poder ler o valor retirado
E agora? Perdemos esse valor. Rede sangrenta, como se atreve!
Ok, eu admito que isso foi impertinente, mas é um bom exemplo de um conceito simples local que realmente não equivaleria à mesma coisa quando tentamos em um contexto distribuído. Quando confrontado com esse buraco, as pessoas tentam imediatamente corrigir isso com “Certo!, então precisamos de uma chamada extra para …”. Para isso, eu enfatizo: “Mas não era uma chamada extra que você estava tentando evitar, em primeiro lugar?”.
Por que você não permite que as funções de usuário sejam escritas em <INSERIR SUA LINGUAGEM FAVORITA AQUI>?
Esta é uma boa pergunta, e existem várias respostas, a maioria delas erradas. Por exemplo, qualquer coisa ao longo das linhas de “Eu não gosto de sua linguagem” precisa ser rejeitada porque a fofura de uma língua é irrelevante.
Existem várias dificuldades com a ideia de oferecer funções de usuário que sejam escritas em outra linguagem de programação. Para as linguagens de script como Python, Lua e PHP, podemos implementar nosso próprio intérprete e oferecer um subconjunto da linguagem, que é ter um monte de trabalho com baixo retorno sobre o investimento, ou integrar um intérprete existente/tempo de execução que provavelmente não irá reproduzir bem com Lwt, ou com o tempo de execução OCaml (coletor de lixo). Para linguagens compiladas, podemos ir através do ffi, mas é muito mais complexo para gente. Então, por agora, você está agarrado com OCaml para funções de usuário. Existem linguagens piores.
Não seria melhor se você aplicasse o resultado da função de usuário para o log de transações iso dos argumentos?
Tenho pensado muito sobre isso antes de termos começado com as funções de usuário. A alternativa é a de registar e fazer o efeito da função de usuário, de modo que possamos sempre reproduzir esse efeito mais tarde, mesmo quando o código já não estiver disponível. É uma alternativa interessante, mas não é uma melhoria clara. Tudo depende do tamanho dos argumentos vs o tamanho do efeito.
Algumas funções de usuário têm um conjunto de argumentos pequeno e um grande efeito, enquanto para outras funções de usuário acontece o contrário.
Palavras de encerramento
Tecnicamente, não é muito difícil conectar suas próprias funcionalidades no Arakoon. Apenas certifique-se de que a coisa na qual você quer se conectar não tenha grandes falhas.
Se divirta.
***
Artigo traduzido pela Redação iMasters, com autorização do autor. Publicado originalmente em http://blog.incubaid.com/2013/02/01/user-functions-in-arakoon/