APIs e Microsserviços

21 ago, 2012

Repensando uma API de banco de dados incorporado

Publicidade

Um dos projetos em que a equipe de pesquisa do Incubaid está trabalhando é um novo banco de dados incorporado (onde “incorporado” se refere ao caminho de outros projetos como o GNU DBM, Berkeley DB, Tokyo Cabinet ou SQLite são usados). Esse projeto, de codenome Baardskeerder, está ainda começando e estamos brincando com abordagens diferentes para vários problemas.

O próprio banco de dados não expõe uma interface completa SQL, como a SQLite faz: a interface fornece a capacidade de armazenar valores para uma determinada chave, recupera o valor armazenado em uma chave, e algumas operações mais complexas como pesquisas de alcance.

A API para uma operação get parece bastante óbvia à primeira vista: você transmite em uma chave, e o valor correspondente será devolvido se a chave tiver sido definida antes, ou a não-existência é sinalizada de alguma maneira (nota: todos os pseudocódigos neste artigo são Haskell):

01 import qualified Data.ByteString.Char8 as BS
02 import Data.ByteString.Char8 (ByteString)
03  
04 type Key = ByteString
05 type Value = ByteString
06  
07 data DB = DB
08  
09 get :: DB -> Key -> Maybe Value
10 get d k = undefined

Uma vez que o banco de dados é mantido (por exemplo, um arquivo em um sistema de arquivos em um disco), e provavelmente será usado pelo servidor de aplicações de estilo, retornando os valores para algum cliente, enquanto o servidor de aplicações não se importa com os valores próprios, esse estilo de API possui uma grande desvantagem: ele força a cópia dos valores de espaço do kernelspace para o userspace, após o qual o valor é escrito em algum socket, copiando do userspace para kernelspace, sem quaisquer modificações intermediárias, ou mesmo de acesso.

Em kernels mais modernos, incluindo Linux, BSD, Solaris e outros, existem várias chamadas de sistemas disponíveis que permitem uma enviar dados de arquivos em um socket, sem essa cópia inútil e do userspace. Elas incluem sendfile(2) e splice(2). Queremos ser capazes de suportar essas otimizações a partir de aplicativos incorporando da biblioteca de banco de dados, enquanto continua a fornecer a API “básica” também.

Uma abordagem seria implementar uma chamada getAndSendOnSocket personalizada na biblioteca, mas isso parece muito ad-hoc. Outra forma de combater isso é retornar um descritor de arquivo, um offset e o tamanho de uma chamada get, mas isso não parece certo, pois não queremos vazar um descritor de arquivo para o aplicativo host como está.

O que temos agora é uma abordagem orientada a callback/continuation, na qual um aplicativo host pode fornecer uma ação que terá acesso ao descritor de arquivo, obter um offset e valor de comprimento, e pode fazer o que quiser, embora seja envolto em um handler de exceção.

A assinatura de nosso get torna-se

1 get' :: DB -> Key -> (Result -> IO a) -> IO a

Aqui está uma implementação de pseudocódigo:

01 import Prelude hiding (concat, length, lookup)
02 import Data.Binary
03 import Data.ByteString.Char8
04 import qualified Data.ByteString.Lazy as BL
05 import Data.Int (Int32)
06 import Control.Exception (bracket, finally)
07 import Foreign.Marshal.Alloc
08 import Foreign.Marshal.Array
09 import Network.Socket (Socket)
10 import Network.Socket.ByteString
11 import Network.Socket.SendFile.Handle
12 import System.Posix (COff, Fd, fdToHandle)
13 import System.Posix.IO.Extra (pread)
14  
15 type Length = Int32
16  
17 type Key = ByteString
18 type Value = ByteString
19  
20 data DB = DB
21  
22 data Result = NotFound
23             | Value Value
24             | Location Fd COff Length
25  
26 -- Lookup a value in the database
27 lookup :: DB -> Key -> IO Result
28 lookup d k = undefined
29  
30 -- Take a reference to the database
31 -- This is dummy, the actual API is more complex
32 ref :: DB -> IO ()
33 ref d = undefined
34  
35 -- Return a reference to the database
36 unref :: DB -> IO ()
37 unref d = undefined
38  
39 get' :: DB -> Key -> (Result -> IO a) -> IO a
40 get' d k h = do
41     v <- lookup d k
42  
43     let (pre, post) = case v of
44             Location _ _ _ -> (ref, unref)
45             otherwise -> (\_ -> return (), \_ -> return ())
46  
47     pre d
48     h v `finally` post d

Observe que um Result pode ser NotFound, um valor real (no nosso caso, isso pode acontecer se o valor é compactado ou criptografado no disco), ou um ponteiro para o valor em um determinado arquivo.

Implementar o get clássico se torna algo trivial:

01 get :: DB -> Key -> IO (Maybe Value)
02 get d k = get' d k h
03   where
04     h :: Result -> IO (Maybe Value)
05     h NotFound = return Nothing
06     h (Value v) = return $ Just v
07     h (Location f o l) =
08         Just `fmap` bracket
09             (mallocArray l')
10             free
11             -- TODO This assumes pread(2) always returns the requested number
12             -- of bytes
13             (\s -> pread f s l' o >> packCStringLen (s, l'))
14       where
15         l' :: Int
16         l' = fromIntegral l

A implementação de uma chamada get que envia um valor em um socket, prefixado com o tamanho do valor, se torna simples:

01 sendOnSocket :: DB -> Key -> Socket -> IO ()
02 sendOnSocket d k s = get' d k h
03   where
04     h :: Result -> IO ()
05     h NotFound = sendMany s zero
06     h (Value v) = do
07         sendMany s $ BL.toChunks $ encode $ (fromIntegral $ length v :: Length)
08         sendAll s v
09     h (Location f o l) = do
10         sendMany s $ BL.toChunks $ encode l
11         f' <- fdToHandle f
12         sendFile' s f' (fromIntegral o) (fromIntegral l)
13  
14     zero = BL.toChunks $ encode (0 :: Int32)

Em vez de sendFile’, algumas ligações FFI para splice ou algo semelhante podem também ser bem usadas.

Na atual pseudoimplementação, um descritor de arquivo ainda pode ser vazado (um aplicativo pode chamar get’ com uma ação que armazena o Fd dado em alguns IORef), e as ações dadas podem alterar o ponteiro do arquivo usando lseek(2) e outros. Poderíamos resolver esse problema, se necessário, duplicando o descritor de arquivo usando dup(2) e passando a cópia para o aplicativo, em seguida, fechando a duplicação após a conclusão (então não há nenhum uso para o aplicativo armazenar o descritor: ele vai se tornar inválido de qualquer maneira).

***

Texto original disponível em http://blog.incubaid.com/2011/09/30/rethinking-an-embedded-database-api/