Net-sftp: Solução para timeout durante o upload

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
Share