Desenvolvendo um script em ruby para upload de arquivos, utilizando a gem net-sftp, acabei encontrando um problema. A falta de timeout durante a transferência.
Decidi escrever esse post, para compartilhar a minha solução e também receber opniões e outras soluções para o problema. Fiz algo parecido quando escrevi o post sobre o cron de 15 em 15 segundos e obtive resultados bem legais.
Introdução ao problema
A gem net-sftp é bastante utilizada em scripts ruby que requerem alguma transferência de arquivo utilizando uma conexão segura, como por exemplo, upload, download, criação de diretórios e etc… Utilizando a gem net-ssh para estabelecer essa conexão.
Primeiramente, vamor ver um exemplo do problema:
1 2 3 4 5 | require 'rubygems' require 'net/sftp' Net::SFTP.start('192.168.0.2', 'rafaelbiriba', {:password => 'teste', :timeout => 3}) do |sftp| sftp.upload!("/Users/rafaelbiriba/Projects/temp/video-teste-sftp.mp4", "/home/rafaelbiriba/video-teste-sftp.mp4") end |
O código acima faz o upload de um arquivo de mais de 400 Mb. Se durante o upload houver um problema na sua conexão, o upload fica “esperando” a rede voltar, o que é um problemão, pois ele trava a execução do script, sem dar nenhum sinal de vida.
De acordo com a documentação do net-sftp, você pode utilizar o método “upload” no lugar de “upload!” para não travar a execução do script. Mas ainda sim o problema continua. Fazendo isso, você vai precisar rodar um loop até que o upload termine. Ou seja, se a conexão cair, o método só vai terminar(e sair do loop) quando a conexão voltar. Então essa opção foi logo descartada.
O problema mesmo só acontece quando há perda de conexão depois que a sessão inicia. (O parâmetro :timeout apenas estabelece o limite de espera para iniciar a sessão e não durante a transferência dos arquivos.)
Se o net-sftp / net-ssh aceitasse a opção “ServerAliveInterval” (Parametro de configuração do ssh), ele iria verificar a conexão durante a transferência e ao alcançar o limite (“ServerAliveCountMax”) seria disparado o timeout. Infelizmente, de acordo com a documentação do net-ssh esses parâmetros não estão disponíveis.
Seguindo com a leitura e busca pela solução na documentação do net-sftp, achei uma possível pista para encontrar a solução:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | require 'rubygems' require 'net/sftp' Net::SFTP.start('192.168.0.2', 'rafaelbiriba', {:password => 'teste', :timeout => 3}) do |sftp| sftp.upload!("/Users/rafaelbiriba/Projects/temp/video-teste-sftp.mp4", "/home/rafaelbiriba/video-teste-sftp.mp4") do |event, uploader, *args| case event when :open then # args[0] : file metadata puts "starting upload: #{args[0].local} -> #{args[0].remote} (#{args[0].size} bytes}" when :put then # args[0] : file metadata # args[1] : byte offset in remote file # args[2] : data being written (as string) puts "writing #{args[2].length} bytes to #{args[0].remote} starting at #{args[1]}" when :finish then puts "all done!" end end end |
Acima temos um exemplo simples retirado da documentação para o controle do progresso de upload.
Quando o upload inicia, o case recebe o evento “:open” onde podemos obter o tamanho total do arquivo e logo em seguida recebe os eventos “:put” onde é possível verificar o quanto (em bytes) do arquivo já foi enviado, possibilitando assim a criação da porcentagem do upload.
Como o foco aqui não é mostrar como obter o progresso (posso mostrar isso exclusivamente em outro post calculando o progresso durante o upload), não vou entrar muito em detalhes sobre isso. O que realmente inporta nesses eventos são que durante o upload (:put), ao perder a conexão, nenhum evento é disparado, ou seja, o script não tem como sabe que a conexão caiu e a execução fica travada no upload.
Minha solução:
Depois de todo esse estudo detalhado sobre o problema, comecei a pensar numa possível solução. Eu precisava utilizar alguma coisa que pudesse verificar e dar timeout no upload caso ele parasse de responder/transferir.
Foi então que pesquisei, testei e implementei o rufus-scheduler*, um agendador de tarefas que roda dentro do script ruby(mas numa outra thread), onde posso definir um intervalo de execução para executar alguma coisa, como se fosse um cron. ( *Sugestão do @vicentemundim )
Então a solução foi: Assim que o upload começar, eu defino um tempo de 10 segundos para ele executar uma tarefa com “raise exception”. Então toda vez que o upload responder ao evento (:put), informando que conseguiu subir mais um trecho do arquivo, eu reseto o tempo de execução da tarefa e defino novamente em 10 segundos. Fazendo isso, quando a conexão cair ou o upload travar por algum motivo, ele vai parar de responder ao evento “:put”, e então a tarefa agendada para 10 segundos vai disparar o raise, interrompendo a execução do script.
Depois de alguns testes, tanto de desempenho quanto de falhas, percebi que essa resolução serviu perfeitamente. Segue o exemplo do código utilizado:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | require 'rubygems' require 'rufus/scheduler' require 'net/sftp' @job = nil @scheduler = Rufus::Scheduler::PlainScheduler.start_new def @scheduler.handle_exception (job, exception) abort(exception.message) end def job_scheduler @job.unschedule unless @job.nil? @job = @scheduler.every "10s" do raise "SFTP Connection Lost or upload freezed timeout" end end Net::SFTP.start('192.168.0.2', 'rafaelbiriba', {:password => 'teste', :timeout => 3}) do |sftp| sftp.upload!("/Users/rafaelbiriba/Projects/temp/video-teste-sftp.mp4", "/home/rafaelbiriba/video-teste-sftp.mp4") do |event, uploader, *args| case event when :open then puts "starting upload: #{args[0].local} -> #{args[0].remote} (#{args[0].size} bytes}" job_scheduler when :put then puts "writing #{args[2].length} bytes to #{args[0].remote} starting at #{args[1]}" job_scheduler when :finish then puts "all done!" @job.unschedule unless @job.nil? end end end |
Quando o upload inicia (:open) e durante a transferência (:put), ele chama o método job_scheduler que é responsável por resetar a tarefa anterior(se houver) e agendar uma nova com timeout de 10 segundos.
Conforme expliquei acima, se o upload travar, o tempo limite de 10 segundos é alcançado e uma exceção é disparada.
Como o agendador do rufus roda em outra thread, qualquer exceção disparada é capturada e tratada pelo próprio rufus, fazendo com que a execução do script continue. Então, sobrescrevi o método (handle_exception) e ao receber a exceção, gero um abort que irá interromper todo o script.
Infelizmente, são poucos os sites relacionados sobre esse assunto, o que dificultou bastante a busca e o desenvolvimento. Mesmo assim, acredito ter resolvido o problema com uma solução bem razoável.
Já falei em cima, mas vale ressaltar:
“Decidi escrever esse post, para compartilhar a minha solução e também receber opniões e outras soluções para o problema. Fiz algo parecido quando escrevi o post sobre o cron de 15 em 15 segundos e obtive resultados bem legais.”
Estou pensando em tentar solucionar isso direto no código do net-sftp e quem sabe mandar um patch para o autor. De qualquer forma, fico no aguardo de qualquer comentário e/ou soluções melhores.
handle_exception |