Back-End

1 abr, 2013

Funções de usuário em Arakoon

Publicidade

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:

  1. o cliente chama a função QDemo.pop
  2. o cluster retira o valor da fila e o master envia essa informação para o cliente
  3. 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/