domingo, 18 de novembro de 2012

Ruby, Colecções, Representação de Dados: Perigos da Meta-Programação

Oi,

Hoje queria falar do perigo que ás vezes representa extender objectos dinâmicamente e de como isso representou uma utilização indevida de memória no projecto em que trabalho correntemente. Consideremos uma classe normal:

 class Bola  
 end  

Se agora nós quisermos marshalizar (http://en.wikipedia.org/wiki/Marshalling_%28computer_science%29) uma instância da mesma, a sua representação é
relativamente minimal. Observemos:

 require 'yaml'  
 b = Bola.new  
 b.to_yaml  
 --- !ruby/object:Bola {}  

(vou apresentar os resultados em formato Yaml porque é mais legível e serve o propósito do post melhor). Portanto, a representação do objecto é relativamente sucinta. Adicionemos-lhe agora uns quantos atributos:

 class Bola  
   attr_accessor :material, :marca, :dono  
   def initialize  
   end  
 end  

instanciemos de novo o objecto:

 require 'yaml'  
 b = Bola.new  
 b.to_yaml  
 --- !ruby/object:Bola {}  

A mesma representação obtida anteriormente. Apesar de a classe ter sido extendida, um objecto que não tenha esses atributos preenchidos não os irá marshalizar. Passemos então ao caso de uso:

 b.material = "cautchu"  
 b.to_yaml  
 --- !ruby/object:Bola  
 material: cautchu  

E aqui temos: o valor do nosso atributo preenchido é usado na representação marshalizada do nosso objecto b. O valor do nosso atributo não é só usado, ele próprio é também marshalizado:

 "cautchu".to_yaml  
 --- cautchu  

 Imaginemos então que um dos nossos atributos é preenchido, em vez de com um valor de um tipo "primitivo" de ruby, com um objecto de uma classe criada por nós:

 class Dono  
  attr_accessor :nome  
  def initialize(nome)  
   @nome = nome  
  end  
 end  
 b.dono = Dono.new("Ronaldinho")  
 b.to_yaml  
 --- !ruby/object:Bola  
 material: cautchu  
 dono: !ruby/object:Dono  
  nome: Ronaldinho  

Interessante. Experimentemos algo agora totalmente alucinado:

 class Dono  
  attr_accessor :bola  
  def initialize(nome, bola)  
   @nome = nome  
   @bola = bola  
   end  
 end  
 b.dono = Dono.new("Pelé", b)  
 b.to_yaml  
 --- &2151878980 !ruby/object:Bola  
 material: cautchu  
 dono: !ruby/object:Dono  
  nome: Pelé  
  bola: *2151878980  

Graças a Deus, houve reconhecimento da referência circular neste caso. De forma curiosa, admitamos. Foi atribuído um endereço ao nosso b, que depois é referenciado dentro do dono. No entanto, vamos insistir:

 class Dono  
  attr_accessor :nome, :bolas  
  def initialize(nome, bola)  
   @nome = nome  
   @bolas = [Bola.new, Bola.new]  
   @bolas.each { |b| b.dono = self }  
   @bolas << bola  
   end  
 end  
 b.dono = Dono.new("Pelé", b)  
 b.to_yaml  
 --- &2152946720 !ruby/object:Bola  
 dono: &2153008060 !ruby/object:Dono  
  nome: Pelé  
  bolas:  
  - !ruby/object:Bola  
   dono: *2153008060  
  - !ruby/object:Bola  
   dono: *2153008060  
  - *2152946720  

Começamos a notar um padrão aqui: esse endereço pode ser uma outra coisa qualquer... continuemos:

 
 class Dono < String  
  attr_accessor :bolas  
  def initialize(nome, bola)  
   super(nome)  
   @bolas = [Bola.new, Bola.new]  
   @bolas.each { |b| b.dono = self }  
   @bolas << bola  
   end  
 end  
 b.dono = Dono.new("Pelé", b)  
 b.to_yaml  
  --- &2165175520 !ruby/object:Bola  
 dono: !ruby/string:Dono  
  str: Pelé  
  bolas: &2165264600  
  - !ruby/object:Bola  
   dono: !ruby/string:Dono  
    str: Pelé  
    bolas: *2165264600  
  - !ruby/object:Bola  
   dono: !ruby/string:Dono  
    str: Pelé  
    bolas: *2165264600  
  - *2165175520  

E agora adensou. Pelos vistos, um objecto tem uma representação marshalizada por defeito. Se for um PORO, é utilizado o id do objecto. No caso de o valor ser uma String, mesmo que extendida, a sua representação vai conter elementos da mesma.

Onde poderá isto ser um problema, perguntarão vocês? A resposta é: em todo o sítio onde a sua representação é marshalizada/yamlizada: serialização, filas de mensagens, etc... Uma representação interminavelmente extensa pode dar origem a uma falha inesperada. Isso aconteceu com o nosso projecto na semana passada: A nossa aplicação Rails utiliza a gem Delayed Jobs para gerir as filas de mensagens. Quando "retardamos" um procedimento, o que a gem faz é criar um registo para a tarefa, com um campo chamado handler onde é guardada a representação yamlizada do método a chamar, dos seus argumentos e do âmbito onde o método é chamado, geralmente um objecto. Este registo é posteriormente persistido na base de dados. Para o tal campo handler é utilizado o tipo TEXT (usamos MySql, já agora). Este tipo é erroneamente interpretado como sem-limite, ou seja, adapta-se ás circunstâncias, mas é errado: tem um limite de 65000 bytes. Quando retardamos uma chamada a um procedimento de um modelo (utilizamos ActiveRecord), esse modelo é yamlizado nesse handler. Felizmente no caso do ActiveRecord, as associações não são yamlizadas, somente os atributos directos e as chaves estrangeiras (ver http://apidock.com/ruby/Object/to_yaml_properties para entender como a gem Delayed Jobs consegue isso). Logo, na maior parte dos casos (salvo data serializada), tipos simples. Agora, vamos imaginar que um destes campos é uma string. Mas na minha lógica de negócio, essa String é extendida mais ou menos na forma descrita nos exemplos acima (mas com mais alguns níveis de profundidade, obviamente). Vamos dizer que atribuímos essa String extendida a um dos campos do nosso modelo ActiveRecord. É uma String. É persistida com sucesso. Mas é a representação marshalizada idêntica? Pois, não é... Imaginemos que temos um modelo AR chamado Bola (tema recorrente, não gosto de usar o nome "Teste" para testes), e que a nossa bola guarda o nome do seu dono num VARCHAR com limite 255, mapeado para uma String na lógica de negócio. numa situação Normal:

 b = Bola.new(:dono => Dono.new("Pelé", b))  
 b.save  
 b.to_yaml  
 --- !ruby/ActiveRecord:Bola  
 attributes:  
  id: 1  
  dono: !ruby/string:Dono  
  str: Pelé  
  bolas: &2165264600  
  - !ruby/object:Bola  
   dono: !ruby/string:Dono  
    str: Pelé  
    bolas: *2165264600  
  - !ruby/object:Bola  
   dono: !ruby/string:Dono  
    str: Pelé  
    bolas: *2165264600  
  - *2165175520  
 b.reload  
 b.to_yaml  
 --- !ruby/ActiveRecord:Bola  
 attributes:  
  id: 1  
  dono:Pelé  

Pois é, duas representações diferentes, dependendo de como a String é instanciada. Acontece então que ocorreu um caso em que a representação yamlizada do objecto sobre o qual determinada chamada tinha sido retardada era simplesmente incomportável pelo campo da base de dados. Não foi motivo de queixa para a base de dados, ela simplesmente guardou a capacidade que suportava. O pior foi quando o script do delayed job tentou executar essa tarefa: obviamente, não conseguiu parsear a representação yamlizada porque era inválida. A representação era deveras gigantesca. Não vou entrar em pormenores sobre o porquê, porque aí teria que vos contar algumas inconsistências da classe extendida de String utilizada (uma pergunta retórica: alguém conhece alguma estrutura de dados para colecções em que os seus elementos guardam um ponteiro para a colecção a que pertencem? Isso foi parte do problema.) Fui falar com o meu colega que escreveu este código. Ele viu a representação e achou que a solução seria passar de TEXT para MEDIUMTEXT ou LONGTEXT (TEXT com mais capacidade). Portanto, se tá gordo, estica, que concerteza não engorda mais... Defendeu a sua ideia quanto baste, porque não queria admitir que a sua preciosa refactorização das definições da aplicação tinha umas quantas fugas de memória. Como disse, colecções em que elementos apontam para a colecção, como será que um garbage collector interage com isto... Então resolvi o problema forçando a conversão para String na atribuição de valores desse tipo extendido... criando um outro problema que foi a propagação de chamadas ao to_s... Para além de a) não ter a garantia que o próximo desenvolvedor não chegue lá e simplesmente se esqueça da conversão e b) persistir o problema se algum dia tiver que retardar a chamada a uma rotina de uma dessas Strings extendidas. Uma grande salganhada proporcionada pela meta-programação de Ruby, uma ferramenta óptima em teoria mas uma fonte de fugas de memória, bugs não-rastreáveis e mau desenho de arquitectura geral. Podemos argumentar que a culpa não é tanto da ferarmenta em si mas do desenvolvedor que falseia o seu uso. Eu diria que cada caso é cada caso.

Sem comentários:

Enviar um comentário