View Original Text

Hide Table of Contents

"I choose to code it in BASH.... I choose to code it in BASH and other things in one week, not because it is easy, but because it is HARD!!!"

#lisíadas #DevOpLife
(modesto, eu, não?)

net.lisias.retro.file.search.WS

Documentação do W/S Repo Search (bleh... que nome dou pra isso?)

Não botei autenticação nem controle - logo, sim, o RPi está bem suscetível à um DoS. Testes empíricos demonstraram que o meu upstream doméstico é uma limitação, mas também um deterrance por efeito colateral. Um upstream decente, e o W/S efetivamente sufocaria o resto da máquina trabalhando nas responses. Não, não tem cache (isso vai ficar por conta do NGINX, quando isso subir pro retro.lisias.net - aliás, o QoS vai ficar por conta dele também).

Tudo roda num Raspberry Pi 2011.12 Model B. A idéia original era montar serviços que rodassem em computadores de recursos mínimos, daqueles que se leva para os Encontros de Retrocomputação para apoio ao evento. Usei um RPi porque era o que tinha na mão, mas isso vai rodar com certeza (talvez até melhor) naqueles Thin Client de segunda mão que tão pipocando na Sta Efigênia (tem de 50 a 100 reais).

Serviço REST W/S

A Jóia da Coroa. :-) Implementado em Python 3 . Roda bem no CPython, mas o PyPy3 5.5.0 faz o mesmo trabalho 3.75 vezes mais rápido (mas demora 21 vezes mais para ler os dados, a I/O do PyPy3 é tenebrosa, pelamordedeus!). Tô servindo no CPython mesmo até o PyPy3 estabilizar.

Uma fuçada rápida nas páginas já diz tudo o que precisa saber para usar o serviço, mas segue uma descrição rápida:

Dado o endereço base, que no momento é http_//home.lisias.net_8081/ (trocar _ por :), o request sempre segue a forma

/<algumacoisa>.<formatodesejado).

Não usar o <formatodesejado> implica numa response em text/plain (adequado para consultas manuais e debugging). Também reconhece .json e .jsonp (json com padding, para furar cross-domain scripting) e o format binario de serialização do Python, Pickle (protocolo 3). Sempre GET, e os eventuais parâmetros da Query seguem a rfc3986 (ou pelo menos, é o que o Bottle me disse).

Se faz um request que existe mas com dados inconsistentes, recebe um 200 com um Response explicando o erro. Se você faz um request que não existe, recebe um 404. Se você faz um request que existe mas com dados inválidos, leva um 500 na orelha e fica por isso mesmo.

Toda resposta retorna um campo booleano chamado error. Se False, success_msg tem uma mensagem de confirmação (normalmente um contador) e os demais campos são o payload da Response - dependente de Request. Se houve um erro, error_msg dá a razão por extenso, e error_code um identificador único no sistema do erro, para automatizar tratamento de erro.

Uma resposta de sucesso pode retornar um campo timetaken_in_secs onde o tempo gasto, em segundos, no processamento do request é informado num valor float. Responses com coleções podem ter este dado em cada item da coleção. Este campo é opcional, e pode ou não estar presente - incluindo entre um Request e outro do mesmo server.

Requests implementados:

/version.<fmt>

retorna a identificação da aplicação e a versão corrente. Bom para saber se o serviço tá de pé (ping). Retorna os formatos reconhecidos pelo W/S (bom para saber o que vc pode usar na hora, já que novos formatos virão).

/metadata.<fmt>

retorna os datasources reconhecidos pelo W/S (asimov, wos, etc). Na medida que novos datasources são adicionados, vai-se atualizando esta chamada.

/<datasource>/metadata.<fmt>

retorna as entidades reconhecidas por este datasource, bem como seus verbos de ação. Cada entidade vem numa tupla, com o singular e com o plural. Se o Request retorna UMA entidade, use o singular. Se o Request retorna uma coleção, use o plural. Os verbos também vêm em tuplas, com o seu nome e uma lista das entidades que reconhecem.

Exemplo:

Estas duas chamadas serão importantes na Confederação, uma vez que cada W/S será capaz de explicar o que entende e como acessar, e o agregador poderá fazer o roteamento. Também é legal para fazer um cliente mais espertinho (ao contrário das minhas páginas de exemplo, que são burrinhas de dar dó).

Verbos implementados

Uma vez escolhido o <datasource>, a mecânica é sempre a mesma:

/<datasource>/<entitiy_plural>/<verb>.<fmt>?<parms> (onde parms é rfc3986 )

/<datasource>/<entitiy_plural>/list.<fmt>

para obter todos os valores de uma entidade.

Por exemplo, /asimov/classes/list.jsonp?callback=callback_16 vai retornar um JSONP com todas as classes reconhecidas pelo datasource asimov:

callback_16({"success_msg": "Classes:7", "classes": [["incoming", 3], ["documentation", 5991], ["asm", 1], ["images", 20460], ["utility", 187], ["unsorted", 624], ["emulators", 513]], "error": false});

Cada valor é uma tupla com dado e incidência - no exemplo acima, há 20460 tuplas cadastradas para a Classe "images".

/<datasource>/<entity_plural>/search.<fmt>

Até agora, só brincamos de metafísi... uh... metadados :-) . A razão de ser do W/S é o verbo que segue:

Ela faz a busca pelas entidades (no momento, apenas "files") dado um critério de busca, que são as entidades acima. Uma busca para o wos segue:

Requests "Confederativos"

Para gerenciamento do Pool de Proxies (a "Confederação"), o seguinte request foi implementado.

É o único POST em todo o Serviço.

/announce.<fmt>

É usado por um proxy.WS para anunciar que este serviço foi incluído na sua Unidade Confederativa.

Não é necessário tomar nenhuma atitude, mas espera-se que o Provider inclua o endereço mencionado no Referer na sua lista de proxies em que está registrado - para sair desregistrando durante o shutdown do serviço.

Datasources

Online

Datasources e seus repositórios. O Primeiro é o que está sendo servido no momento. As demais URLs serão servidas quando implementar suporte para mirrors. Dead repos são repositórios que não são mais atualizados, e portanto não precisam checar cache após a primeira carga - se o arquivo existe, já veio, se não existe, não adianta tentar de novo.

Como zombie entende-se repositórios que estiveram dead por anos, e tiveram uma ou duas updates nos últimos 12 meses. Estão cadastradas como deads até eu descobrir (se existir) uma forma barata de checar novidades.

troubled significa que estou tendo algum problema com ele, normalmente baixar alguns arquivos.

Esperando hosts

Estes aqui estão prontos, mas não tenho máquina (ainda) para subi-los.

Work in Progress

Vou documentar isso aqui porque preciso guardar esta lista em algum lugar, senão me perco. :-)

Endereço atual

http://search.lisias.net/retro

Interesting Issues Solved

Algumas issues foram de doer. A pior delas foi performance. Numa tentativa (quase) desesperada de melhorar a performance da carga dos dados no RPi, dei uma chance pro PyPy3 5.5.0 (ainda alfa). Me estrepei, porque o tempo de carga piorou uma barbaridade (VINTE VEZES, pô!!) - mas a bateria de testes do WebService ficou QUATRO VEZES MAIS RÁPIDA.

Bom, vale a pena perseguir isso. Então faz cProfile daqui, faz cProfile de lá... E descobri que o problema se concentrava em DUAS funções (todo o resto era acelerado uma barbaridade no PyPy!). Uma era minha (que explodia em 200 vezes em relação ao CPython), a outra é o str.translate (que só explodia 10).

Deste último não tem o que fazer, o str do PyPy3 é lerdo mesmo. Mas nem era o pior problema. Dureza era o que EU estava fazendo que no PyPy que em geral tem uma performance cavalar, pontualmente neste caso virava uma carroça de bois, com os ditos cujos mancos, com joanete e hemorróidas.

Pelo menos a função tinha só 7 linhas - a análise combinatória era baixa. :-)

dict.keys()]

Fazendo Test Beds da função em questão, um para cada cenário, cheguei à uma conclusão reveladora =P : eu tava fazendo bobagem! Vindo do Java, eu achei uma boa idéia checar se uma chave já era usada num Dict assim:

if key in my_dict.keys():
    do_something()

No CPython funciona legalzinho, mas esta maldita linha no PyPy3 fica VINTE VEZES MAIS LERDA. o.O

Fuça daqui, pesquisa dali, xinga a mãe dos caras acolá, eu vi na documentação deles que o Garbage Collector do PyPy3 é bem menos performático que o do CPython (não vou entrar em detalhes).

Daí caiu a ficha! No Python3, a função dict.keys() retorna um objeto do tipo dict_keys, que é no fundo um iterator, e não uma lista como eu estava acostumado!!!! Daí que para funcionar como eu esperava, o Python iterava tudo numa list e dava pro if fazer o trabalho dele e depois jogava a lista fora. Em outras palavras, olha a memória enchendo de lixo aí.... =/

Daí é que me toquei que a mesmo resultado se obtêm (e de forma mais pythomática =P ) assim:

if key in my_dict:
    do_something()

Fazendo profilling no CPython, não fez diferença NENHUMA. Imagino que os caras do CPython tenham de alguma forma feito uma otimização marota :-) neste "idioma", mas no PyPy3 a função passou a seguir a norma das demais e ficou 4 vezes mais rápida!

str.translate

Esta aqui me quebrou as pernas. Apesar de não causar um dano tão feio quanto ao pepino da dict_keys, também mordia um bocado. Me enchi de coragem e ataquei este problema também.

Eu não sei exatamente qual era o gargalo, mas estava na implementação da str.translate do PyPy3. Lendo a documentação, vi que a str foi implementada em Python puro, não era uma interface com uma biblioteca em C. Num chute calculado, apostei também no Garbage Collector, e procurei uma forma de fazer o mesmo serviço sem apelar para as funções da str.

Cheguei no pattern:

    parts = []
    for c in [ c for c in string if c not in Translate.SET_OF_CHARS ]:
        parts.append(c)
    return ''.join(parts)

Para substituir um string.translate cuja tabela seja {ord(character):None for character in SET_OF_CHARS}.

Outro pattern que usei foi:

    parts = []
    for c in string :
        parts.append(c if c in SET_OF_CHARS else " ")
    return ''.join(parts)

Para substituir um string.translate cuja tabela seja {ord(character):" " for character in SET_OF_CHARS}.

A razão é que ao invés de concatenar strings, que gera lixo pra cacete (e então o GC, ponto fraco do PyPy3, nos morde os calcanhares), o ''.join não gera quase nenhum. Isto está documentado no FAQ do PyPy.

Só que, e de novo, "O remédio do Pato é veneno pro marreco." =/ Isso virtualmente f*deu a performance no CPython. (sigh).

Abrir mão do PyPy3 à esta altura do campeonato não me agradava nem um pouco, mas não dá pra abrir mão do CPython tampouco. O jeito foi encapsular estas chamadas dentro duma Fachada, que em tempo de carregamento checa o runtime corrente e gera funções dinamicamente:

class Translate:
    '''
    Class needed to abstract CPython and PyPy performance differences
    '''
    SET_OF_CHARS = set([chr(i) for i in some_criteria()])
    CHAR_MAP = {ord(character):" " for character in SET_OF_CHARS}

    if 'PyPy' == platform.python_implementation():
        @staticmethod
        def do_some_translation(string : str):
            parts = []
            for c in string:
                parts.append(" " if c in Translate.SET_OF_CHARS else c)
            return ''.join(parts)
    else:
        @staticmethod
        def do_some_translation(string : str):
            return string.translate(Translate.CHAR_MAP)

E então sair usando Translate.do_some_translation() onde eu precisava. Óbvio que para cada CHARMAP, tinha que ter uma função equivalente, a minha Fachada tem umas 5 funções diferentes para cada runtime.

E foi isso. Estes dois pequenos =P hacks resolveram meus problemas de performance no PyPy3 sem ferrar com o CPython.

Resultado final:

Carga de dados

Curioso que embora os datasources menores tenham comido mais tempo de processamento, o WoS e o AmiNet (que são o dobro dos menores juntos!) foram internalizados em tempo menor (JIT?). Acabou que o tempo de carga total melhorou em todos os casos. Curiosamente, foi justo no RPi que os ganhos percentuais foram melhores.

Bateria de Testes do Web Service

Os resultados dos testes de performance da bateria de testes foram:

O PyPy3 consistentemente entregou uma performance superior a 400% em todos absolutamente os casos (depois que, óbvio, eu me livrei das fraquezas dele!). No RPi, esteve acima de 550% em quase todos (menos um) casos.

É muita coisa pra se deixar passar.

Uma vez que a máxima "O remédio do pato normalmente é veneno pro marreco" vale aqui, eu criei fachadas que dinamicamente definem funções em função do ambiente de execução - de forma que eu continuo desenvolvendo em CPython (porque nem todo ambiente de execução tem um PyPy3!), e na hora do runtime as otimizações são usadas automaticamente.

Para se ter uma idéia do problema original, abaixo a tabela de performance antes e depois das duas otimizações para o PyPy3. Apenas no Desktop, porque os tempos no RPi são estupidamente grandes para valer a pena obter estes dados (20 a 45 minutos por teste, sem chance). A Bateria de Testes não sofreu nenhuma diferença depois das otimizações (o código que tinha o "bug" era pouco usado, a incidência foi virtualmente nula), de forma que fiz estes testes apenas para a carga.

Datasource Carga Antiga CPython Carga Antiga PyPy Carga Nova CPython Carga Nova PyPy
asimov 6.976422 secs 36.006185 secs 6.882563 secs 6.649997 secs
msxarchive 3.339096 secs 14.853703 secs 3.027886 secs 3.828372 secs
wos 26.094766 secs 436.094230 secs 27.642663 secs 23.894624 secs
(total) 36.410284 secs 486.954118 secs 37.553112 secs 34.372993 secs

Os números falam por si mesmos (datasources não mencionados não estavam disponíveis durante a transição).