Algoritmos – Conversão Binária – Parte 03

Introdução

No post anterior sobre conversão binária, vimos algumas formas de converter um valor binário representado em uma string de volta a um valor decimal. O algoritmo mais rápido demorou 9,2 segundos para converter 2.560.000 valores. E se eu disser que dá para fazer isso QUATRO VEZES mais rápido, eu devo estar louco ???

Acelerando o AdvPL

No primeiro post sobre conversão binária, o algoritmo têm um desempenho incrível para converter valores de decimal para string binária, pois eu monto um cache com um array armazenando as strings  com o resultado das conversões dos valores de 0 a 255, e ao converter eu acesso diretamente o elemento pela sua posição, o tempo de busca é praticamente o mesmo para qualquer elemento.

Agora, será que é possível aplicar isso da forma inversa ? Usar um array em cache, com as strings binárias e o seu valor decimal correspondente, e buscar o valor desejado a partir da string informada ? Considerando uma string de 8 caracteres ‘0’ ou ‘1’ , com 256 possibilidades diferentes, é mais rápido calcular o número em decimal ou procurar na lista ? Veremos …

Peso do algoritmo de cálculo

Partindo da conversão de uma string binária de 8 caracteres, usando o algoritmo proposto no post anterior, podemos elencar o melhor e pior tempo da rotina, partindo das operações que ela precisa fazer para chegar ao resultado final. Vamos avaliar quantas operações de string, comparação e aritmética são necessárias para converter as strings “00000000” e “11111111” respectivamente:

User Function TST8()
Local nRet 
nRet := BitStoN("00000000")
nRet := BitStoN("11111111")
return

STATIC Function BitsToN(cBitStr)
Local nI, nT := len(cBitStr) 
Local nMult := 1
Local nResult := 0
For nI := nT to 1 STEP -1
    // a linha abaixo é executada 8 vezes, uma para 
    // identificar cada caractere da string binária
    // e compará-lo com '1'
    IF substr(cBitStr,nI,1) = '1'
        // A soma do multiplicador somente é executada para
        // cada caractere '1' encontrado na string. Ela 
        // nao é executada nenhuma vez para "00000000", mas é
        // feita 8 vezes para converter "11111111"
        nResult += nMult
    Endif
    // A Multiplicação abaixo é feita sempre 8 vezes
    nMult *= 2
Next
Return nResult

Passando na ponta do lápis, a busca mais “leve” de uma string binária fará 8 extrações de uma posição da string (substr), 8 comparações de string de 1 caractere e 8 multiplicações de nMult. Para cada dígito encontrado que tenha o  valor ‘1’ ainda será feita uma soma adicionando o valor de nMult no resultado. Logo, com “11111111”, temos mais oito operações aritméticas. Podemos concluir que, o número estimado de operações dessa rotina é de 24 a 32 operações de string e numéricas por conversão.

HASH MAP

Quem se lembra do artigo sobre Performance e Escalabilidade – HASH MAP em AdvPL ? Então, um Hash Map é uma forma otimizada de busca em vetores do tipo chave-valor, que monta internamente um acesso posicional. Se, internamente, a quantidade e peso das operações para fazer a busca usando um HASHMAP em uma lista pré-montada é menor do que calcular o resultado, o HASH vai ser mais rápido. Vamos ao teste:

User Function tststr()
Local cStrBit
Local nTimer
Local nNum, nI , nJ      
Local oHash

// Cria o cache de HASH para a busca ( 0 a 255 )            
oHash := BuildBitHash()

// Roda a carga de teste
nTimer := seconds()
For nJ := 0 to 255              
  // Cria uma string binária para conversão
  cStrBit := nToBit8(nJ)
  For nI := 1 to 10000 
    // Busca o valor no HashMap
    HMGET( oHash,cStrBit ,@nNum)
  Next
Next
// Mostra o tempo do teste
conout(str(seconds()-nTimer,12,3)+"s.")

// Limpa de destroi o HashMap
HMClean(oHash) 
FreeObj(oHash)

Return

// Monta o HashMap com os 256 bytes de cache
STATIC Function BuildBitHash()
Local oHash := HMNew()
For nI := 0 to 255
  // Seta uma tupla chave , valor 
  HMSet(oHash, NTOBIT8(nI) , nI )
Next
Return oHash

Desempenho

Usando a função BitSToN(), 2.560.000 conversões levaram em média de 8,5 a 9 segundos para serem realizadas. Usando o objeto de Hash Map, o mesmo processamento levou em média 2 segundos. Isso significa quase 1280000 conversões por segundo, contra no máximo 320.000 conversões por segundo da BitSToN()

Observações

O tempo obtido foi realmente impressionante, porém para chegar neste desempenho, dada a quantidade de iterações, o objeto de cache foi montado e armazenado em variável local, e as chamadas para a busca do HashMap foram feitas com a função HMGet(), sem encapsulamento em Static Function. Após implementar o encapsulamento do HMGET() por Static Function, o tempo foi para perto de 4 segundos, o que ainda é a metade do tempo do algoritmo anterior. 😀

Conclusão

Sempre dá pra “apertar” mais um pouco o desempenho, usando as técnicas e abordagem adequadas.

Agradeço a todos a audiência, e lhes desejo TERABYTES DE SUCESSO !!! 

Referências

TDN – HashMap

 

 

MemCached Client em AdvPL – Parte 01

Introdução

O MemCached é um aplicativo que provê um cache de objetos em memória, do tipo chave/valor de alto desempenho. Ele possui APIs Client para várias linguagens de mercado, e agora também terá a sua API Client em AdvPL.

O MemCached

Open Source, Free , originalmente desenvolvido para Linux, ele também têm porte para Windows, sobe originalmente com um limite de 64 MB de memória para uso com elementos em cache, aceita conexões na porta 11211, em todas as interfaces de rede da máquina. Existe parametrização para permitir mudar a porta e colocar mais de uma instância de MemCached na mesma máquina, definir uso de mais memória, e inclusive permitir conexões apenas em uma interface de rede ou IP. Recomendo a leitura da documentação — pelo menos a abordagem inicial — disponível no site MemCached.ORG

Classe ZMEMCACHED

Como o mecanismo do MemCached trabalha com TCP/IP, a classe implementa a API de acesso para as funcionalidades do MemCached. Damos o nome de Chave a um identificador de conteúdo, uma string com até 150 bytes, e associamos a esta chave um conteúdo a ser colocado em cache. Este conteúdo pode ser colocado no cache com ou sem um tempo de vida (expires). As funcionalidades básicas incluem acrescentar, atualizar e remover uma tupla chave/valor do cache, incrementar ou decrementar um valor em cache — neste caso o conteúdo em cache deve ser um número — representado como string, obter status do cache, e limpar todo o cache. Vamos ao fonte: (fonte zMemCached,prw)

#include "protheus.ch"
#include "zLibStr2HexDmp.ch"

CLASS ZMEMCACHED FROM LONGNAMECLASS
 
   DATA cMemCacheIP     // IP da instancia Memcached
   DATA nMemCachePort   // Porta da instancia Memcached
   DATA nRvcTimeOut     // Timeout de recebimento em milissegundos ( default 1000 ) 
   DATA oTCPConn        // Objeto Socket Client
   DATA cError	        // Ultimo erro da API 
   DATA cResponse       // Response header da ultima requisicao
   DATA lVerbose        // Modo verbose de operação 
   
   METHOD New()         // Construtor da classe
   METHOD Connect()     // Estabelece conexão com o MemCached
   METHOD GetVersion()  // Recupera a versao do MemCached
   METHOD GetStats()    // Recupera estatisticas da intancia do memcached
   METHOD Disconnect()  // Desconecta do MemCAched

   METHOD Add()         // Acrescenta uma chave / valor ( apenas caso nao exista ) 
   METHOD Replace()     // Troca o valor de uma chave existente
   METHOD Set()         // Armazena uma chave / valor no MemCached 

   METHOD Get()         // Recupera o valor de uma chave armazeanda 
   METHOD Delete()      // Remove do cache um valor pela chave 
   METHOD Increment()   // Incrementa um contador pela chave -- valor em string numerica
   METHOD Decrement()   // Decrementa um contador pela chave -- valor em string numerica
   METHOD Flush()       // Limpa todas as variáveis do cache 

   // ********* METODOS DE USO INTERNO *********
   METHOD _Store( cMode, cKey , cValue, nOptFlag, nOptExpires )
   METHOD _GetTCPError()
  
ENDCLASS

 

Método NEW

Method NEW( cIp , nPorta ) CLASS ZMEMCACHED
::cMemCacheIP := cIp
::nMemCachePort := nPorta
::nRvcTimeOut := 1000
::oTCPConn := tSocketClient():New()
::cError := ''
::cResponse := ''
::lVerbose := .F.
Return self

O construtor da classe Client do MemCached recebe logo de cara o IP e Porta da instância do MemCached que ele deve utilizar. Ele já cria um objeto do tipo tSocketClient() para conversar com o MemCached, mas ainda não estabelele a conexão, apenas inicializa as propriedades de uso da classe.

Método CONNECT

METHOD Connect() CLASS ZMEMCACHED
Local iStat

::cError := ''
::cResponse := ''

IF ::lVerbose
	Conout("zMemCached:Connect() to "+::cMemCacheIP+" Port "+cValToChar(::nMemCachePort))
Endif

If ::oTCPConn:Isconnected()
	::cError := "Memcached client already connected."
	Return .F.
Endif

// Estabelece a conexao com o memcache DB
iStat := ::oTCPConn:Connect( ::nMemCachePort , ::cMemCacheIP, 100 )

If iStat < 0
	::cError := "Memcached connection Error ("+cValToChar(iStat)+")"
	::_GetTCPError()
	Return .F.
Endif

O método Connect apenas estabelece a conexão TCP no IP e Porta especificados no construtor. Nesta etapa, ele não faz nenhum tipo de HandShake — por hora — ele apenas abre a conexão TCP/IP. Em caso de erro, o método retorna .F., e a razão do erro pode ser recuperada na propriedade ::cError.

Método DISCONNECT

METHOD Disconnect() CLASS ZMEMCACHED
Local cSendCmd := 'quit'+CRLF
Local nSend

::cError := ''
::cResponse := ''

If ::oTCPConn == NIL
	::cError := "Memcached client already Done."
	Return .F.
Endif

if( ::oTCPConn:IsConnected() )
	// Se ainda está conectado, manda um "quit"
	// para fechar a conexao de modo elegante
	IF ::lVerbose
		Conout("zMemCached:DONE() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
		Conout(Str2HexDmp(cSendCmd))
	Endif
	nSend := ::oTCPConn:Send( cSendCmd )

	If nSend <= 0 
		::cError := "Memcached client SEND Error."
		::_GetTCPError()
		Return .F.
	Endif
	
Endif

::oTCPConn:CloseConnection()
::oTCPConn := NIL

Return .T.

O método Disconnect() desconecta do MemCached de forma “elegante”, enviando um aviso de desconexão (instrução quit). Simples assim. Após desconectar, a mesma instância pode ser aproveitada para uma nova conexão — por hora no mesmo IP e Porta — na mesma instância do MemCached.

Método GETVERSION

METHOD GetVersion( cVersion ) CLASS ZMEMCACHED
Local nRecv, cRecvBuff := ''
Local cSendCmd := "version" + CRLF
Local nSend

::cError := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

IF ::lVerbose
	Conout("zMemCached:GetVersion() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv < 0
	::cError := "Receive Error"
	::_GetTCPError()
	Return .F.
Endif

IF ::lVerbose
	Conout("zMemCached:GetVersion() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

If Left(cRecvBuff,8)!='VERSION '
	::cError := "Response Error : " + cRecvBuff
	Return .F.
Endif

// Recupera a versão por referencia 
cVersion := ::cResponse

Return .T.

O método GetVersion() deve passar a variável cVersion por referência — prefixada com “@” na chamada da função. Em caso de sucesso, a função retorna .T., e o valor da variável será atualizado. Caso contrário, a variável será NIL, e a mensagem de erro correspondente está na propriedade ::cError

Método GETSTATS

METHOD GetStats( aStats ) CLASS ZMEMCACHED
Local nRecv, cRecvBuff := ''
Local nI , nT , aTmp
Local cSendCmd := "stats" + CRLF
Local nSend

::cError := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

IF ::lVerbose
	Conout("zMemCached:GetStats() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv < 0
	::cError := "Receive stats error"
	::_GetTCPError()
	Return .F.
Endif

If nRecv == 0
	::cError := "Receive stats time-out
	::_GetTCPError()
	Return .F.
Endif

IF ::lVerbose
	Conout("zMemCached:GetStats() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

// Recupera estatisticas
aTmp := strtokarr2( strtran(cRecvBuff,CRLF,chr(10)) , chr(10) )

nT := Len(aTmp)
For nI := 1 to nT
	If Left(aTmp[nI],5)=='STAT '
		aadd(aStats , substr(aTmp[nI],6) )
	Endif
Next

// Limpa o array temporario
aSize(aTmp,0)

Return .T.

Cada instância do MemCached têm seus mecanismos internos de controle. Usando o método GetStats(), podemos perguntar ao MemCached as estatísticas de uso até o momento. As informações são retornadas por referência no Array aStats passado como parâmetro, onde cada linha é uma string contento um identificador e seu respectivo valor, veja o exemplo abaixo:

pid 11128
uptime 10
time 1547326165
version 1.4.5_4_gaa7839e
pointer_size 64
curr_connections 10
total_connections 11
connection_structures 11
cmd_get 0
cmd_set 0
cmd_flush 0
get_hits 0
get_misses 0
delete_misses 0
delete_hits 0
incr_misses 0
incr_hits 0
decr_misses 0
decr_hits 0
cas_misses 0
cas_hits 0
cas_badval 0
auth_cmds 0
auth_errors 0
bytes_read 16
bytes_written 26
limit_maxbytes 67108864
accepting_conns 1
listen_disabled_num 0
threads 4
conn_yields 0
bytes 0
curr_items 0
total_items 0
evictions 0
reclaimed 0

Método _STORE

// ===============================================================================
// Guarda um valor no memcache
// Mode = set, add, replace, append, prepend
// cas ainda nao implementado 

METHOD _Store( cMode, cKey , cValue, nOptFlag, nOptExpires ) CLASS ZMEMCACHED
Local cSendCmd := ''
Local nRecv
Local cRecvBuff := ''
Local nSend

::cError := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

If !( ('.'+cMode+'.') $ ('.set.add.replace.append.prepend.cas.') )
	::cError := "Invalid Store mode ["+cMode+"]"
	Return .F.
Endif

// <mode> <key> <flags> <exptime> <bytes>
// ------------------------------------------
cSendCmd += cMode + ' '
cSendCmd += cKey + ' '
If nOptFlag == NIL
	cSendCmd += '0 '
else
	cSendCmd += cValToChar(nOptFlag)+' '
Endif
If nOptExpires == NIL
	cSendCmd += '0 '
else
	cSendCmd += cValToChar(nOptExpires)+' '
Endif
cSendCmd += cValToChar(len(cValue))
cSendCmd += CRLF
// ------------------------------------------

IF ::lVerbose
	Conout("zMemCached:_Store() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

// Etapa 01 Envia o comando 
nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif


// Etapa 02
// Envia o valor a ser armazenado 

nSend := ::oTCPConn:Send(cValue+CRLF)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

If ::lVerbose
	Conout("zMemCached:Store("+cMode+") SEND VALUE ")
	Conout(Str2HexDmp(cValue+CRLF))
Endif

// Se tudo der certo, aqui eu devo receber um "stored"
nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv < 0
	::cError := "Store("+cMode+") failed - connection error" + cValTochar(nRecv)
	::_GetTCPError()
	Return .F.
Endif

If nRecv == 0
	::cError := "Store("+cMode+") failed - response time-out"
	::_GetTCPError()
	Return .F.
Endif

If ::lVerbose
	Conout("zMemCached:Store("+cMode+") RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

cRecvBuff := strtran(cRecvBuff,CRLF,'')

If cRecvBuff != 'STORED'
	::cError := "Store ["+cMode+"] failed: "+cRecvBuff
	Return .F.
Endif

Return .T.

O método _STORE é de uso interno da classe. Ele é usado pelos métodos públicos ADD, REPLACE e SET. Internamente, a sintaxe dos comandos e o retorno é praticamente o mesmo para estas três ações de armazenamento. Logo, optei por criar um método interno capaz de realizar as três operações, e os três métodos públicos para consumir estas ações no fonte AdvPL.

Métodos ADD, REPLACE e SET

METHOD Add( cKey , cValue, nOptExpires ) CLASS ZMEMCACHED
Return ::_Store("add", cKey , cValue, NIL, nOptExpires)

METHOD Replace( cKey , cValue, nOptExpires ) CLASS ZMEMCACHED
Return ::_Store("replace", cKey , cValue, NIL, nOptExpires)

METHOD Set( cKey , cValue, nOptExpires ) CLASS ZMEMCACHED
Return ::_Store("set", cKey , cValue, NIL, nOptExpires)

Em cada um destes três métodos, informamos a chave de identificação do dado, o valor a ser gravado em cache, e opcionalmente podemos especificar um tempo de vida (expires) em segundos no cache. O default é 0 (zero=no expires). Todos os métodos acima retornam .F. quando a operação não pode ser realizada.

A diferença entre eles é que:

  1. ADD() somente var armazenar o valor caso a chave ainda não tenha sido gravada anteriormente. Ela não atualiza valor de chave existente.
  2. Replace() somente troca o valor de uma chave existente. Caso você tente trocar o valor de uma chave que não existe, ela retorna uma condição de erro.
  3. O método SET sempre atualiza o valor de uma chave, se ela ainda não existe no cache, ela é criada.

Método GET

METHOD Get( cKey , cValue ) CLASS ZMEMCACHED

Local cSendCmd := ''
Local nRecv
Local cRecvBuff := ''
Local nPos
Local cLine
Local aTmp
Local cTeco
Local nSize
Local nSend

::cError := ''
::cResponse := ''

// Limpa o retorno por referencia 
cValue := NIL

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	return -1
Endif

// Monta o comando de recuperacao 
cSendCmd += 'get '+cKey + CRLF

If ::lVerbose
	Conout("zMemCached:Get() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

// Manda o comando
nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

// Se tudo der certo, aqui eu devo receber os dados ...
nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

If nRecv < 0
	::cError := "Get() failed - connection error" + cValTochar(nRecv)
	::_GetTCPError()
	return -1
Endif

If nRecv == 0
	::cError := "Get() failed - response time-out"
	::_GetTCPError()
	return -1
Endif

If ::lVerbose
	Conout("zMemCached:Get() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

// Parser do retorno

While !empty(cRecvBuff)
	
	// Primeiro pega a linha de status
	nPos := at(CRLF,cRecvBuff)
	If nPos < 1
		::cError := "Get() failed - missing CRLF"
		return -1
	Endif
	
	cLine := left(cRecvBuff,nPos-1)
	cRecvBuff := substr(cRecvBuff,nPos+2)
	
	If cLine == "END"
		// acabaram os dados
		// Sai do loop
		EXIT
	Endif
	
	If Left(cLine,6) == "VALUE "
		
		// Tem valor ... opa ... legal
		aTmp := strtokarr2(cLine,' ')
		
		// varinfo("aTmp",aTmp)
		// [1] "VALUE"
		// [2] <key>
		// [3] <flags>
		// [4] <size> 
		// [5] Optional [uniqueid]
		
		nSize := val(aTmp[4])
		
		While len(cRecvBuff) < nSize
			
			// Se ainda falta coisa pra receber, recebe mais um teco
			// e acrescenta no buffer
			cTeco := ''
			nRecv := ::oTCPConn:Receive(@cTeco,::nRvcTimeOut)
			
			If nRecv < 0
				::cError := "Get() failed - connection error" + cValTochar(nRecv)
				::_GetTCPError()
				return -1
			Endif
			
			If nRecv == 0
				::cError := "Get() failed - response time-out"
				::_GetTCPError()
				return -1
			Endif
			
			If ::lVerbose
				Conout("zMemCached:Get() RECV "+cValToChar(nRecv)+" Byte(s)")
				Conout(Str2HexDmp(cTeco))
			Endif
			
			// So acrescenta o que recebeu
			cRecvBuff += substr(cTeco,1,nRecv)
			
			If ::lVerbose
				Conout("zMemCached:Get() Total ReceivedBuffer ")
				Conout(Str2HexDmp(cRecvBuff))
			Endif
			
		Enddo
		
		// Valor ja foi recebido na integra
		// Coloca o valor recebido no retorno
		cValue := left(cRecvBuff,nSize) 
		
		// Arranca valor recebido do buffer
		// Ja desconsiderando o CRLF
		cRecvBuff := substr(cRecvBuff,nSize+3)
		
		// Limpa o array temporário
		aSize(aTmp,0)

		// Eu só espero recener um valor 		
		EXIT 
		
	Else
		
		// Se nao tem o valor, ou nao tem o "END", deu merda ?!
		::cError := "Get() failed - Unexpected ["+cLine+"]"
		return .F. 
		
	Endif
	
Enddo

If empty(cRecvBuff)
	// Se o buffer esta vazio, 	entao nao chegou nenhum valor 
	// A operação de GET foi feita com sucesso, 
	// naou houve erro, apenas o valor nao foi encontrado. 
	Return .T. 
Endif

If left(cRecvBuff,5) == "END" + CHR(13)+Chr(10)
	// Depois do valor, eu espero um END (CRLF) \
	// Se nao chegou um END, tem mais de um valor na chave ? ....
	Return .T. 
Endif

::cError := "Get() failed - Unexpected Multiple Value(s)"
return .F.

O método GET foi feito para recuperar o valor em cache associado a uma chave. A variável para receber o valor é informado por referência na chamada do método. O fonte é um pouco mais “rebuscado” pois precisa permanecer recebendo dados do MemCache enquanto ele não enviar o conteúdo inteiro armazenado.

Um detalhe importante: A função somente retorna .F. em caso de ERRO, por exemplo perda de conexão ou resposta inesperada ou não tratada do MemCached. Se o valor a ser recuperado na chave não existe no cache, isto não é considerado um erro, logo o método vai retornar .T. , e cabe ao desenvolvedor verificar se o dado retornado por referência não está NIL.

Método DELETE

Usamos o método DELETE para remover uma chave e seu valor associado do cache.

METHOD Delete( cKey ) CLASS ZMEMCACHED
Local cSendCmd 
Local nRecv
Local cRecvBuff := ''
Local nSend

::cError    := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

cSendCmd := 'delete ' + cKey + CRLF

// ------------------------------------------

If ::lVerbose
	Conout("zMemCached:Delete() SEND")
	Conout(Str2HexDmp(cSendCmd))
Endif

// Manda o comando 
nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

// Se tudo der certo, aqui eu devo receber DELETED 
nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

// Pega apenas a primeira linha do resultado 
::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv < 0
	::cError := "Delete() failed - connection error" + cValTochar(nRecv)
	::_GetTCPError()
	Return .F.
Endif

If nRecv == 0
	::cError := "Delete() failed - response time-out"
	::_GetTCPError()
	Return .F.
Endif

If ::lVerbose
	Conout("zMemCached:Delete() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

cRecvBuff := strtran(cRecvBuff,CRLF,'')

If cRecvBuff != 'DELETED'
	::cError := "Delete failed - Error: "+cRecvBuff
	Return .F.
Endif

Return .T.

Métodos INCREMENT e DECREMENT

Podemos armazenar no cache — usando Add ou Set — um vamor numérico representado em string em uma determinada chave. E, usando os métodos Increment() e Decrement(), podemos respectivamente aumentar ou diminuir o valor desta chave. Internamente o MemCached não vai deixar duas operações de incremento rodar ao mesmo tempo. Cada operação realizada retorna o novo valor da chave após a operação ser realizada. O valor é recuperado por reverência na chamada do método, no parâmetro nValue.

Method Increment( cKey , nValue , nStep ) CLASS ZMEMCACHED

Local cSendCmd := ''
Local nRecv
Local cRecvBuff := ''     
Local nSend

::cError := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

// Monta o comando de recuperacao
cSendCmd += 'incr '+cKey+' '
If nStep == NIL
	cSendCmd += '1'
Else
	cSendCmd += cValToChar(nStep)
Endif

cSendCmd += CRLF 

If ::lVerbose
	Conout("zMemCached:Increment() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

// Manda o comando
nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

// Se tudo der certo, aqui eu devo receber o valor apos o incremento
nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv < 0
	::cError := "Increment() failed - connection error" + cValTochar(nRecv)
	::_GetTCPError()
	Return .F.
Endif

If nRecv == 0
	::cError := "Increment() failed - response time-out"
	::_GetTCPError()
	Return .F.
Endif

If ::lVerbose
	Conout("zMemCached:Increment() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

// Parser do retorno
cRecvBuff := strtran(cRecvBuff,CRLF,'')

If !(left(cRecvBuff,1)$'0123456789')
	::cError := "Increment() failed - Error "+cRecvBuff
	::_GetTCPError()
	Return .F.
Endif

// Pega e retorna o valor apos o incremento
nValue := val(cRecvBuff)

Return .T.

Method Decrement( cKey , nValue , nStep ) CLASS ZMEMCACHED

Local cSendCmd := ''
Local cRecvBuff := ''
Local nRecv
Local nSend

::cError := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

// Monta o comando de recuperacao
cSendCmd += 'decr '+cKey+' '
If nStep == NIL
	cSendCmd += '1'
Else
	cSendCmd += cValToChar(nStep)
Endif

cSendCmd += CRLF

If ::lVerbose
	Conout("zMemCached:Decrement() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

// Manda o comando
nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

// Se tudo der certo, aqui eu devo receber o valor apos o decremento
nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv < 0
	::cError := "Decrement() failed - connection error" + cValTochar(nRecv)
	::_GetTCPError()
	Return .F.
Endif

If nRecv == 0
	::cError := "Decrement() failed - response time-out"
	::_GetTCPError()
	Return .F.
Endif

If ::lVerbose
	Conout("zMemCached:Decrement() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

// Parser do retorno

cRecvBuff := strtran(cRecvBuff,CRLF,'')

If !(left(cRecvBuff,1)$'0123456789')
	::cError := "Decrement() failed - Error "+cRecvBuff
	Return .F.
Endif

// Pega e retorna o valor apos o decremento
nValue := val(cRecvBuff)

Return .T.

Método FLUSH

E, para finalizar, se eu quiser evaporar com todo o conteúdo em cache — todas as chaves e valores armazenadas — eu chamo o método Flush().

METHOD Flush() CLASS ZMEMCACHED
Local nRecv, cRecvBuff := ''
Local cSendCmd := "flush_all" + CRLF
Local nSend

::cError := ''
::cResponse := ''

If !::oTCPConn:Isconnected()
	::cError := "Memcached client not connected."
	Return .F.
Endif

IF ::lVerbose
	Conout("zMemCached:Flush() SEND "+cValToChar(len(cSendCmd))+" byte(s).")
	Conout(Str2HexDmp(cSendCmd))
Endif

nSend := ::oTCPConn:Send(cSendCmd)

If nSend <= 0 
	::cError := "Memcached client SEND Error."
	::_GetTCPError()
	Return .F.
Endif

nRecv := ::oTCPConn:Receive(@cRecvBuff,::nRvcTimeOut)

::cResponse := substr(cRecvBuff,1,at(CRLF,cRecvBuff)-1)

If nRecv == 0
	::cError := "Receive timed-out"
	::_GetTCPError()
	Return .F.
Endif

If nRecv < 0
	::cError := "Receive Error"
	::_GetTCPError()
	Return .F.
Endif

IF ::lVerbose
	Conout("zMemCached:Flush() RECV "+cValToChar(nRecv)+" Byte(s)")
	Conout(Str2HexDmp(cRecvBuff))
Endif

If Left(cRecvBuff,2)!='OK'
	::cError := "Response Error : " + cRecvBuff
	Return .F.
Endif

Return .T.

Programa de Testes

Para verificar as funcionalidades do Cache, criei um programa de testes da funcionalidade client da API, para confirmar os comportamentos, segue fonte abaixo:

#include 'Protheus.ch'
#include 'zLibStr2HexDmp.ch'

// ---------------------------------------------------------------------------------
// Utiliza direto uma inistancia da classe ZMEMCACHED 
// para ler e gravar valores, testa todas as operações, expires e contador/sequenciador

#define TEST_HOST		'localhost'
#define TEST_PORT		11211

User Function MemTst1()

Local oClient
Local cVersion := ""
Local aStats := {}
Local nI , nX
Local xValue 
Local nNewValue

oClient := ZMEMCACHED():New( TEST_HOST , TEST_PORT )

// Modo verbose apenas para depuração 
// oClient:lVerbose := .t.

IF !oClient:Connect()
   conout("Falha de conexão...")
   conout(oClient:cError)
   return
Endif

// Recupera a versao da instancia atual
If !oClient:GetVersion(@cVersion)
   conout("Falha ao recuperar versao...")
   conout(oClient:cError)
   return
endif

conout("Memcache Version: "+cVersion)

// Pega as estatisticas da instancia atual 
If !oClient:GetStats(@aStats)
   conout("Falha ao recuperar estatisticas...")
   conout(oClient:cError)
   return
endif

conout(padc(" STATISTICS ",79,"-"))
aEval(aStats , {|x| conout(x) })

// Apaga todas as chaves

If !oClient:Flush()
   conout("Falha na limpeza global...")
   conout(oClient:cError)
   return
endif

// Testando armazenamento de valores
cValue1 := RandomStr(64)
cValue2 := RandomStr(64)

// Acrescenta o valor 
// Fuciona apenas caso a chave nao exista 
If !oClient:Add( 'chave' , cValue1 )
	conout("Falha ao adicionar chave ...")
	conout(oClient:cError)
	Return 
Endif

// Agora tenta acrescentar na mesma chave
// isso nao deveria ser possivel 
If oClient:Add( 'chave' , cValue1 )
	UserException("Permitiu adicionar valor de chave ja existente")
Endif

// Troca valor - apenas se a chave existe 

If !oClient:Replace( 'chave' , cValue2 )
	conout("Falha ao trocar chave ...")
	conout(oClient:cError)
	Return
Endif

// Deleta a chave
If !oClient:Delete( 'chave')
	conout("Falha ao deletar chave ...")
	conout(oClient:cError)
	Return
Endif

// agora tenta trocar o valor. 
// deveria falhar, pois a chave nao existe 
If oClient:Replace( 'chave' , cValue1 )
	UserException("Permitiu troca de valor de chave que nao existe")
Endif

// Acrescenta o valor de novo 
// Deve funcionar, pois a chave tinha sido deletada
If !oClient:Add( 'chave' , cValue1 )
	conout("Falha ao adicionar chave ...")
	conout(oClient:cError)
	Return 
Endif

// Mostra no console o valor graavdo 
conout(padc(" STORED VALUE ",79,"-"))
Conout(Str2HexDmp(cValue1))

// Agora le o valor da chave
lOk := oClient:Get('chave' , @xValue )

If !lOk
	conout("Falha ao ler a chave ...")
	conout(oClient:cError)
	Return 
Endif

conout(padc(" READED VALUE ",79,"-"))
Conout(Str2HexDmp(xValue))

If ! (xValue == cValue1 ) 
	UserException("Divergencia de valor")
Endif

// busca uma chave que nao existe 
lOk := oClient:Get('naoexiste' , @xValue )

If !lOk
	conout("Retorno inesperado")
	conout(oClient:cError)
	Return 
Endif
             
// Cria um contador para incremento
// Ja inicializado com um valor

If !oClient:Add( 'contador' , '666' )
	conout("Falha ao adicionar contador ...")
	conout(oClient:cError)
	Return 
Endif

// Agora testa o incremento 
nNewValue := 0 

If !oClient:Increment( 'contador' , @nNewValue )
	conout("Falha ao incrementar contador ...")
	conout(oClient:cError)
	Return 
Endif

conout("nNewValue = "+cValToChaR(nNewValue))

If nNewValue != 667
	UserException("Incr Failed - Expected 667 Reveived "+cvaltochar(nNewValue))
Endif

If !oClient:Decrement( 'contador' , @nNewValue )
	conout("Falha ao incrementar contador ...")
	conout(oClient:cError)
	Return 
Endif

conout("nNewValue = "+cValToChaR(nNewValue))

If nNewValue != 666
	UserException("Decr Failed - Expected 667 Reveived "+cvaltochar(nNewValue))
Endif

// Agora incrementa um contador que nao existe 
If oClient:Increment( 'contador2' , @nNewValue )
	UserException("Nao deveria incrementar algo que nao existe")
Else
	Conout("-- Falha esperada -- contador realmente nao existe ")
Endif

// teste de valor com timeout                       
// expira em (aproximadamente) 2 segundos

If !oClient:Add( 'timer' , 'teste' , 2 )
	conout("Falha ao adicionar contador ...")
	conout(oClient:cError)
	Return 
Endif

// le o valor 4 vezes em intervalos de 1 segundo 
// A partir da terceira leitura o valor 
// já nao deveria existir 

For nX := 1 to 4
	// le direto o valor 
	lOk := oClient:Get('timer' , @xValue )
	conout(padc(" GET VALUE ",79,"-"))
	If xValue = NIL 
		conout("--- NIL --- ")	
	Else
		Conout(Str2HexDmp(xValue))
	Endif
	If !lOk
		conout("Falha ao ler a chave ...")
		conout(oClient:cError)
		Return 
	Endif
	Sleep(1000)
Next

// Pega as estatisticas no final do teste
If !oClient:GetStats(@aStats)
   conout("Falha ao recuperar estatisticas...")
   conout(oClient:cError)
   return
endif

conout(padc(" MEMCACHED STATSISTICS ",79,"-"))
aEval(aStats , {|x| conout(x) })

oClient:Disconnect()
FreeObj(oClient)

Return

// ---------------------------------------------------------------------------------
// Função RandomStr()
// Gera uma string com o tamanho espeficicado, sorteando caracteres 
// ASICI da faixa de 32 a 127, contemplando letras, números e simbolos 

STATIC Function RandomStr(nSize)
Local cRet := ''
While nSize>0
	cRet += chr(randomize(32,128))
	nSize--
enddo
Return cRet

O programa deve apenas emitir echo no log de console, ele apenas vai abortar a execução com um erro caso ele não consiga conexão, ou no caso de algum comportamento inesperado do MemCached. Os testes foram realizados com um MemCached 1.4.5 para Windows, mas a camada TCP é a mesma para Linux.

Cache Distribuído

Quem já usa APIs de MemCached deve estar se perguntando: — Onde eu acrescento os servidores do MemCached? Bem, esta é a primeira versão de client, então ela conecta com apenas uma instância de MemCached. O MemCached foi construído para ser um cache distribuído, onde a capacidade de montar um cluster e dividir os dados entre as instâncias online do MemCached é do Client.

Por hora, a implementação deste client em AdvPL conversa com apenas uma instância, que pode ser colocada por exemplo em uma máquina, onde existam mais de um serviço de Protheus Server para consumir o cache.

Cuidados no Uso

  • Recomendo a leitura das recomendações de uso do MemCached no site da ferramenta, tanto sobre dimensionamento como boas práticas de segurança.
  • Um serviço de MemCached normalmente pode ser acessado por mais de um programa Cliente consumidor do Cache, então não crie nomes “curtos demais” para identificar as suas chaves, inclusive preferencialmente crie um padrão de nomenclatura, como o environment + “_”  + Modulo ou programa + “_”  + identificador do cache.

Conclusão

Por hora a API armazena basicamente um buffer em formato Caractere no AdvPL. Inclusive, pode armazenar formato binário, como uma imagem. Caso seja necessário armazenar por exemplo um Array ou outros valores, é necessário convertê-los para Caractere, e depois convertê-los de volta. Estou estudando uma forma rápida de se fazer isso. Na continuação deste post, espero já ter finalizado esta questão de forma elegante.

Fontes da ZLIB

A partir de hoje —  13/01/2019 — os fontes da ZLIB estão em um repositório separado do GITHUB. Por hora só existe o branch “master”, e ele é o que sempre será atualizado. Os fontes existentes no repositorio do blog permanecem lá, mas sem receber atualização — apenas para não quebrar os links dos posts anteriores. Os fontes e includes da zLib estão na URL https://github.com/siga0984/zLIB

Agradeço a todos novamente pela audiência, e lhes desejo TERABYTES DE SUCESSO !!!

Referências

 

Acelerando o AdvPL – Importação de tabelas

Introdução

Existem muitas situações onde existe a necessidade de alimentar ou importar tabelas para uso do ERP Microsiga / Protheus. Quando esta necessidade envolve um grande número de registros, e um curto espaço de tempo, precisamos fazer esta operação ser o mais rápida possível. Nesse post vamos abordar algumas técnicas para realizar este tipo de operação.

  • Crie os índices da tabela apenas após a importação dos dados. 

Se a tabela está vazia, e os indices de trabalho desta tabela já existem, cada novo registro acrescentado faz o banco de dados atualizar todos os índices durante a inserção dos dados. Sem os índices, o banco apenas insere os dados. A inserção vai ser muito mais rápida, e os índices também são criados mais rápido no final do processo de importação, quando você já inseriu todos os registros na tabela. Se você precisa ter um índice na tabela, no processo de importação, por exemplo para fazer alguma validação ou query na tabela enquanto ela está sendo importada, crie apenas este índice.

  • Trabalhe com blocos de inserções transacionadas

Os bancos de dados relacionais garantem a integridade dos dados através do registro de um “LOG Transacional”. Cada operação de inserção primeiro é gravada no LOG do SGDB, e depois é “efetivada” — o Banco de Dados faz o COMMIT da informação. Quando não estamos usando uma transação explícita, cada instrução executada no Banco de Dados faz COMMIT automaticamente no final da instrução. É mais eficiente abrir uma transação — BEGIN TRANSACTION — pois enquanto a transação está aberta, cada instrução gera apenas o LOG Transacional, e depois de fazer por exemplo 1000 inserções na base de dados, você encerra a transação — END TRANSACTION no AdvPL — e neste momento o Banco de dados faz o COMMIT de todas as linhas do LOG de uma vez.

  • A nova tabela que vai receber os dados deve ser criada pelo DBAccess 

Mesmo que você saiba quais são os tipos dos campos da estrutura da tabela final, onde os dados serão gravados, quem sabe criar a tabela nos padrões e moldes necessários para o correto funcionamento da aplicação é o Protheus, que passa para o DBAccess a estrutura da tabela a ser criada. Internamente, o DBAccess cria constraints e outros elementos, além de alimentar algumas tabelas de controle interno. Deixe o DBAccess fazer a criação da tabela. Você pode até apagar os índices de dados para fazer a importação da tabela após eles terem sido criados, porém não apague o índice da primary key (R_E_C_N_O_), e se a tabela possui índice único (sufixo _UNQ), mantenha ele também, caso você queira que o próprio banco de dados aborte a operação no caso de haver uma chave única duplicada nos dados sendo importados.

  • Usando as funções de baixo nível de tabelas do AdvPL 

Caso a importação dos dados seja realizada por uma aplicação AdvPL, que foi criada para esta finalidade, que não vai concorrer com os processos do ERP, podemos usar diretamente as funções de baixo nível de tabelas do AdvPL. As funções de mais alto nível — como RECLOCK e MSUNLOCK devem ser usadas dentro dos programas do ERP, no ambiente do ERP, pois elas têm tratamentos adicionais ligados ao FrameWork do ERP. Se, ao invés disso, usarmos diretamente as funções diretas de manutenção de dados, em um cenário controlado, podemos obter mais ganhos de desempenho — DBAppend() para acrescentar um novo registro, DBRUnlock() — sem nenhum parâmetro — para soltar o bloqueio (lock) de todos os registros obtidos durante aquele processo (lembrando que eu não posso soltar o lock dentro de transação), abrir e fechar transação sem usar BEGIN e END TRANSACTION — Usando diretamente a função TCCommit().

IMPORTANTE

O Uso das funções básicas do AdvPL para a manutenção de tabelas NÃO DEVE SER MISTURADO com o uso das funções do Framework. Dentro de um processo preparado para o contexto de execução do ERP, BEGIN TRANSACTION trabalha junto com END TRANSACTION, Reclock() e MSUnlock() trabalham juntas, ambas possuem tratamentos diferenciados e automáticos quando você programa dentro do ambiente do ERP e/ou dentro de um bloco transacionado, e todas elas internamente fazem uso das funções de baixo nível do AdvPL. Se você vai fazer uma aplicação que não vai rodar dentro do contexto do ERP, como por exemplo as aplicações que eu publico no BLOG — CRUD em AdvPL por exemplo — você deve usar apenas as funções de baixo nível. Misturar chamadas diretas das funções de Framework com chamadas das funções de baixo nível NO MESMO PROCESSO pode causar efeitos imprevisíveis.

  • Abra a tabela de destino em modo exclusivo

Se a sua aplicação vai usar o ambiente do ERP — Prepare Environment, Reclock e afins, abra a tabela de destino em modo exclusivo — veja como na documentação da função ChkFile() do Framework AdvPL. Caso você vá utilizar as funções de baixo nível de acesso a tabelas, veja como abrir uma tabela em modo exclusivo na documentação da função DBUseArea() da TDN ou do comando USE da TDN. Caso a tabela esteja aberta em modo compartilhado, cada inserção de um novo registro faz o DBAccess registrar um bloqueio desse registro na inserção, e cada desbloqueio gera uma requisição a mais ao DBAccess para soltar o bloqueio. Quando usamos a tabela em modo EXCLUSIVO, somente o meu processo está usando a tabela, e não é feito nenhum lock no DBAccess.

  • Use um BULK INSERT ou ferramenta de apoio do próprio Banco de Dados

Não há forma mais rápida de inserir dados em uma tabela senão usando uma ferramenta do próprio Banco de Dados. Porém, nem sempre é possível fazer isso de forma automática, e alguns cuidados são necessários. Primeiro, a tabela já deve ter sido previamente criada pelo DBAccess. Depois, a gravação dos dados pelo Banco deve ser feita da mesma forma que o DBAccess faria — Qualquer campo do tipo caractere deve estar preenchido com espaços em branco até o final do tamanho do campo, nenhum campo pode ter valor NULL — exceto campo MEMO –, um campo “D” Data em AdvPL em uma tabela do DBAccess é gravado como “C” Caractere com 8 posições, no formato AAAAMMDD, uma data vazia são 8 espaços em branco, um campo “L” lógico do AdvPL é criado pelo DBAccess no SGDB como um campo de 1 Caractere, contento a letra “T” para valor verdadeiro e “F” para falso, um campo numérico têm o valor default = 0 (zero).

  • Diminua a distância entre as informações de origem e destino 

Normalmente uma ferramenta do próprio banco de dados que faz inserção deve ser executada na própria máquina onde está o SGDB. Isto a torna ainda mais rápida. Se você está fazendo uma importação de dados que estão sendo lidos pelo Protheus de alguma fonte de dados, e depois repassadas ao Banco de Dados através do DBAccess, e cada um destes componentes (Protheus, DBAccess e o SGDB) estão em máquinas distintas, onde existe uma interface de rede entre cada um destes componentes, fatalmente a rede vai influenciar no desempenho desta operação. Se for possível eliminar pelo menos uma camada de rede entre as aplicações, pelo menos na hora de fazer a importação, ela tende a ser mais rápida.

  • Se posssível, leia as informações da origem com Query

Se a origem dos dados for uma tabela da base de dados, ao invés de abrir a tabela no modo ISAM — com ChkFile() ou DBUseArea() — leia os dados da tabela com um SELECT — O DBAccess a partir de 2017 passou a trafegar blocos de linhas entre o DBAccess e o Protheus, o que é muito mais performático principalmente quando ambos estão em máquinas diferentes. No acesso ISAM emulado, cada DBSkip() na tabela traz apenas um registro. Quando usamos a Query, várias linhas são trafegadas em uma unica requisição, e cada DBSkip() na Query consome a próxima linha em cache sem usar a rede. Quando as linhas do cache terminarem, o DBAccess envia mais um bloco de linhas.

Inserção direta via TCSqlExec()

Da mesma forma que é possível usar uma aplicação externa para inserir dados direto no banco, é possível também realizar a inserção direta no banco em AdvPL usando TCSqlEXec() — MAS, POREM, TODAVIA, CONTUDO, ENTRETANTO, você precisa tomar os mesmos cuidados como se você estivesse alimentando estes dados por fora, E, você somente vai ter algum ganho de desempenho se você montar uma string de parâmetro com mais de uma inserção — por exemplo, inserindo múltiplas linhas na mesma instrução de insert — caso o banco de dados tenha suporte para isso — ou concatenando mais de uma instrução de inserção na mesma requisição, separando as instruções com “;” — ponto e vírgula.

cStmt := "INSERT INTO TABELA(CPO1,CPO2,R_E_C_N_O_) VALUES ('00001','Nome',1);"
cStmt += "INSERT INTO TABELA(CPO1,CPO2,R_E_C_N_O_) VALUES ('00002','Endereço',2);"
(...)
nRet := TCSqlExec(cStmt)

No exemplo acima, você pode por exemplo montar uma string de inserção com por exemplo 32 KB de dados, e enviar isso de uma vez para o banco de dados. Mas, inserir dados desta forma possui vantagens e desvantagens:

  • Uma requisição insere vários registros na mesma instrução. Usando DBAppend() / Reclock(), é inserido um registro por vez.
  • Uma requisição maior no lunar de várias requisições menores aproveita muito mais a banda de rede disponível.

Por outro lado…

  • Para montar a inserção dos valores de forma literal, você deve tratar manualmente a existência de caracteres especiais, aspas simples como conteúdo de campo, e para alguns bancos tratar “escape sequences” e caracteres especiais.
  • Campos MEMO (BLOB/CLOB/LONGVARBINARY) normalmente não são suportados com envio literal pela instrução de INSERT feita desta forma. Você acaba fazendo a inserção primeiro dos dados dos campos de tamanho fixo, e depois tem que usar o modo ISAM de acesso para alterar o registro em AdvPL, para então alimentar o conteúdo do campo.

IMPORTANTE

Nenhum dos métodos acima é recomendável de ser feito em processos concorrentes, em modo compartilhado, com outros programas inserindo informações na tabela em questão. As sugestões deste post mostram apenas jeitos diferentes e alternativas de importação de dados puros direto para tabelas de um banco de dados. Como nestes exemplos os dados são praticamente escritos “direto” nas tabelas, a aplicação que está importando os dados deve ser a responsável por criticar os dados de entrada, e garantir que a sua escrita esteja em conformidade com as regras do ERP. Mesmo uma operação de cadastro feita no ERP pode disparar integrações e processos auxiliares que alimentam outras tabelas. Mexer diretamente na base de dados sem respeitar as regras de negócio e/ou as validações do produto podem gerar comportamentos inesperados e indesejáveis no produto e nas rotinas que consomem estes dados. A forma mais segura de importar dados em tabelas do ERP é a utilização de rotinas automáticas, onde fatalmente vai existir um pênalti de desempenho para validar todos os valores de todos os campos informados, mas desta forma evita-se a alimentação incompleta ou inconsistente de dados que pode prejudicar o comportamento do produto.

Conclusão

Não existe “almoço grátis”, alguém está sempre pagando a conta. Uma inserção de dados é um processo deveras simples, você obtêm maior desempenho basicamente realizando requisições em blocos, e pode ganhar mais reduzindo as operações intermediárias intrínsecas ao processo — porém este ganho normalmente abre mão de validações que visam garantir a integridade das informações imputadas, ou acabam exigindo mais trabalho e etapas adicionais para a conclusão do processo.

Novamente agradeço pela audiência, curtidas e compartilhamentos, e desejo a todos TERABYTES DE SUCESSO !!! 😀

Referências

 

Resolvendo o limite da função Randomize()

Introdução

No post Boas Práticas de Programação – Código Simples, Resultados Rápidos, eu mencionei um limite operacional da função Randomize() do AdvPL. Caso a diferença entre o maior e o menor número a ser sorteado for maior que 32767, a função vai sortear um número maior ou igual ao número inicial informado, e menor que o número inicial mais 32767.

Programa original

A chamada no fonte original era para sortear um número entre 100000 e 999999. Porém, devido a limitação operacional da função Randomize(), o maior resultado seria sempre menor que 132767.

nTmp := Randomize(100000,999999)

Para resolver esta limitação da função, podemos criar uma segunda função de sorteio, onde podemos tratar este limite de uma forma elegante e performática. Vamos criar a USER Function Randomic(). 

USER Function Randomic(nMin,nMax)
Local nDiff := nMax-nMin
Local nDec := 0
If nDiff < 32766
   Return Randomize(nMin,nMax)
Endif
While nDiff > 32765
   nDiff /= 10 
   nDec++
Enddo
nTmp := randomize(0,int(nDiff))
While nDec > 0 
   nTmp *= 10 
   nTmp += randomize(0,10) 
   nDec--
Enddo
Return nMin+nTmp

Como a U_Randomic() funciona

Inicialmente, determinamos usando a variável local nDiff qual é a diferença do maior para o menor número. Caso a diferença seja suportada pela função Randomize(), retornamos direto a chamada para a função Randomize(), passando os parâmetros originais.

Caso a diferença não seja contemplada pela função Randomize(), dividimos a diferença por 10 até que ela entre dentro do intervalo. Cada divisão realizada incrementa uma unidade na variável local nDec, que indica quantas casas da diferença original foram reduzidas. Uma vez que a diferença se enquadre nos valores suportados, chamamos a função Randomize(), para sortear um número entre 0 e o valor inteiro da diferença apurada após as divisões.

Sorteado este número, agora precisamos sortear mais alguns números para completar as casas decimais que foram “cortadas” da diferença original. A cada iteração, para quantas casas decimais foram cortadas — valor guardado na variável nDec — o valor anteriormente sorteado é multiplicado por 10, e um novo valor sorteado entre 0 e 9 é adicionado ao resultado, decrementando uma unidade em nDec. Terminado o processo, o número final sorteado será o número inicial somando com o número  final sorteado, armazenado em nTmp.

Exemplo

Vamos rodar o programa de testes abaixo, chamando uma vez a função U_RANDOMIC() usando o IDE/TDS em modo de depuração.

User Function TstRand()
Local nRand
nRand := U_Randomic(100000,1000000)
conout(nRand)
Return

Ao entrarmos na função U_Randomic(), a diferença entre o numero final e o inicial será de 900000 (novecentos mil). Ao passar pelo primeiro loop, a primeira divisão por 10 faz o número baixar para 90000 (noventa mil), e nDec é incrementado para uma unidade. Como o número ainda é maior que 32765, este loop é executado novamente, onde nDiff baixa agora para 9000 (nove mil) e nDec é incrementado para 2. Como agora nDiff é menor que 32765, o programa continua.

O sorteio do novo número, a ser armazenado na variável nTmp, será feito usando a função Randomize(), informando o valor mínimo 0 e o máximo 9000. Durante a depuração, o número sorteado por exemplo foi 1271.

Agora, como houve a redução de duas casas decimais, o próximo loop será executado duas vezes. Na primeira execução, o número nTmp é multiplicado por 10 — resultando em 12710 — e um novo dígito entre 0 e 9 será sorteado e acrescentado em nTmp. foi sorteado o número 7, e acrescentado em nTmp, fazendo seu valor atual ser 12717. Na segunda execução, este valor foi multiplicado novamente por 10 — resultando em 127170 — e um novo número foi sorteado e acrescentado — foi sorteado o número 4, e nTmp passou a conter o valor 127174.  Nas duas execuções, nDec foi decrementado duas vezes, voltando a ser 0 (zero). Pronto, na ultima linha, acrescentamos o número inicial (100000) ao número sorteado (127174), resultando no número 227174.

Da forma que a função foi escrita, ela torna muito rápido e seguro o processo de sorteio, não havendo chance do valor sorteado ser maior que o especificado como parâmetro para a função.

Operadores especiais utilizados

Quando queremos realizar uma operação aritmética com uma variável, e queremos atualizar esta mesma variável com o valor resultante da operação, normalmente utilizamos uma sintaxe como:

variavel := variavel <operador> <operando>

Por exemplo:

nVar := nVar * 10 
nVar := nVar + 10 
nVar := nVar + 1

Em AdvPL, existem operadores compostos  especiais, que ao mesmo tempo fazem a atribuição e a operação aritmética. Por exemplo, as operações acima podem ser escritas da seguinte forma:

nVar *= 10
nVar += 10 
nVar++

Os operadores +=-=*=  e  /= são binários — exigem dois argumentos, a variável do lado esquerdo que será usada como base para a operação e receberá o resultado, e a expressão do lado direito (variável ou constante), que será utilizada para realizar a operação. Respectivamente são os operadores de soma (+), subtração (-), multiplicação (*) e divisão (/). Já os operadores ++ e — são unários — têm apenas um argumento, que é a variável em questão, e respectivamente somam ou subtraem o valor 1 da variável informada.

Conclusão

Mesmo que alguma função básica da linguagem AdvPL possua alguma restrição operacional, o conhecimento das demais funcionalidades e capacidades da linguagem podem tornar fácil uma implementação de um novo recurso que atenda a sua necessidade.

Agradeço novamente a audiência, e desejo a todos TERABYTES DE SUCESSO !!! 😀

Referências

 

 

Executando Jobs em AdvPL

Introdução

Em todos os posts anteriores sobre escalabilidade, desempenho e afins, sempre aparece o “tal” do JOB. No AdvPL, genericamente damos o nome de “Job” para um processamento de uma função AdvPL iniciada em um ambiente sem interface com o usuário — ou seja, sem conexão com o SmartClient). Neste post, vamos ver em detalhes algumas formas que o AdvPL permite a execução de JOBs.

JOBS “ONSTART”

No arquivo de configurações do Servidor de Aplicação Protheus Server, conhecido por “appserver.ini”, podemos definir um ou mais JOBS, que por default são iniciados apenas uma vez, no momento que o serviço do Protheus é iniciado.

Basta criar uma nova seção nomeada no appserver.ini para cada JOB ou programa que deve ser executado em JOB — lembrando de usar um nome intuitivo para o job, e nao usar nenhuma palavra reservada de configuração — , depois criar uma seção chamada [ONSTART], e dentro dela colocar a lista de jobs a serem executados no início do serviço do Protheus Server dentro da configuração JOBS, separados por vírgula.

Exemplo 01

[ONSTART]
JOBS=JOBMSG1

[JOBMSG1]
main=conout
environment=envp12
nparms=1
parm1="Meu Primeiro Job"

No exemplo acima, configuramos um JOB para ser executado apenas uma vez, na subida do serviço do Protheus Server. O JOB foi configurado para chamar a função “Conout” do AdvPL, para emitir uma mensagem no log de console. Também foi configurado para passar uma string de parâmetro para a função conout(), contendo o texto “Meu Primeiro Job”.

A passagem de parâmetros para um JOB é opcional, as configurações obrigatórias são:

  • MAIN = Identifica o nome da função a ser chamada
  • ENVIRONMENT = Identifica o ambiente de execução desta função no Protheus.

Exemplo 02

[ONSTART]
JOBS=JOBMSG2

[JOBMSG2]
main=conout
environment=envp12
instances=3
nparms=2
parm1="Meu Primeiro Job Multiplo"
parm2="-------------------------"

No exemplo acima, apenas acrescentamos a configuração INSTANCES=3, para indicar para o Protheus Server que ele deve subir três processos (ao invés de apenas um) e executar em cada um deles a mesma função conout(), porém agora com dois parâmetros (a string “Meu Primeiro Job Múltiplo”, seguido de uma string de mesmo tamanho com uma sequencia de ‘-‘). O Resultado no log de console do AppServer deve ser algo parecido com:

Meu Primeiro Job Multiplo
-------------------------
Meu Primeiro Job Multiplo
-------------------------
Meu Primeiro Job Multiplo
-------------------------

Passagem de parâmetros ao JOB

Como vimos nos exemplos acima, podemos passar um ou mais parâmetros para os JOBS, identificando a quantidade de parâmetros a ser enviada usando a configuração NPARMS, e depois cada parâmetro como uma string, identificada como PARM1, PARM2, etc… Os parâmetros neste caso sempre serão passados para as funções a serem executadas como “C” Caractere. Caso os conteúdos dos parâmetros sejam informados entre aspas duplas, as aspas serão removidas automaticamente dos parâmetros para a passagem do valor ao AdvPL.

Exemplo 03

Vamos criar uma função simples para ser usada no lugar do conout() dos exemplos acima. Compile no seu repositõrio a função abaixo:

USER FUNCTION MYJOB1( cMsg1, cMsg2 ) 
conout("Thread ["+cValToChar(ThreadID())+"] executando ... ")
conout("Thread ["+cValToChar(ThreadID())+"] cMsg1 = "+cValToChar(cMsg1))
conout("Thread ["+cValToChar(ThreadID())+"] cMsg2 = "+cValToChar(cMsg2)
sleep(1000)
conout("Thread ["+cValToChar(ThreadID())+"] saindo ... ")
return

Agora, usando a configuração do Exemplo 02, troque na configuração main= conout por main=U_MYJOB1, compile o fonte acima, pare e suba o Servidor de Aplicação novamente, e vamos ver o resultado no LOG de console:

Thread [7048] executando ...
Thread [7048] cMsg1 = Meu Primeiro Job Multiplo
Thread [7048] cMsg2 = -------------------------
Thread [16432] executando ...
Thread [16432] cMsg1 = Meu Primeiro Job Multiplo
Thread [16432] cMsg2 = -------------------------
Thread [16508] executando ...
Thread [16508] cMsg1 = Meu Primeiro Job Multiplo
Thread [16508] cMsg2 = -------------------------
Thread [7048] saindo ...
Thread [16432] saindo ...
Thread [16508] saindo ...

Exemplo 04

Agora, vamos mexer um pouco na função MYJOB1, para ela “nao sair” …

USER FUNCTION MYJOB1( cMsg1, cMsg2 ) 
conout("Thread ["+cValToChar(ThreadID())+"] executando ... ")
conout("Thread ["+cValToChar(ThreadID())+"] cMsg1 = "+cValToChar(cMsg1))
conout("Thread ["+cValToChar(ThreadID())+"] cMsg2 = "+cValToChar(cMsg2)]
While !killapp()
  conout("Thread ["+cValToChar(ThreadID())+"] Hora Atual = "+time())
  sleep(1000)
Enddo
conout("Thread ["+cValToChar(ThreadID())+"] saindo ... ")
return

O resultado esperado no log de console será algo parecido com isso:

Thread [2928] executando ...
Thread [2928] cMsg1 = Meu Primeiro Job Multiplo
Thread [2928] cMsg2 = -------------------------
Thread [2928] Hora Atual = 01:00:21
Thread [13224] executando ...
Thread [13224] cMsg1 = Meu Primeiro Job Multiplo
Thread [13224] cMsg2 = -------------------------
Thread [13224] Hora Atual = 01:00:21
Thread [15056] executando ...
Thread [15056] cMsg1 = Meu Primeiro Job Multiplo
Thread [15056] cMsg2 = -------------------------
Thread [15056] Hora Atual = 01:00:21
Thread [2928] Hora Atual = 01:00:22
Thread [13224] Hora Atual = 01:00:22
Thread [15056] Hora Atual = 01:00:22
Thread [2928] Hora Atual = 01:00:23
Thread [15056] Hora Atual = 01:00:23
Thread [13224] Hora Atual = 01:00:23
Thread [2928] Hora Atual = 01:00:24
Thread [15056] Hora Atual = 01:00:24
Thread [13224] Hora Atual = 01:00:24

A função KillApp() somente retornará .T. caso o processo atual seja derrubado pelo Protheus Monitor, ou caso o serviço do Protheus Seja finalizado. No nosso exemplo, cada job fica em LOOP mostrando a hora atual no log de console, dorme por 1 segundo, e recomeça o loop.

Vale lembrar que um JOB é colocado no ar apenas na instância do Protheus Server na qual ele foi configurado no arquivo de inicialização. Colocar um JOB na seção [ONSTART] do Aplication Server configurado para ser, por exemplo, o servidor Master para Balanceamento de Carga, fará o JOB rodar apenas naquela instância do Protheus Server.

Evolução dos Exemplos

Conhecendo um pouco mais das funções de comunicação entre processos do AdvPL, como por exemplo as funções IPCWAITEX e IPCGO, com mais algumas linhas de código podemos criar um mecanismo de processamento em JOB assíncrono, onde cada job pode ficar esperando uma requisição de IPC usando um mesmo identificador nomeado, e receber notificações de processamento de um ou mais programas sendo executados neste mesmo Serviço do Protheus.

Exemplo 05

Vamos mexer mais um pouco no nosso JOB, para ele ser capaz de receber o nome de uma função de usuário para ser executada em modo assíncrono. Criamos uma função “Cliente”, que solicita o processamento de uma função especifica, para contar de 1 a 10. Colocamos três jobs no ar disponíveis para realizar as chamadas, e executamos a aplicação client, solicitando um novo envio de processamento a cada 1 segundo, por quatro vezes, e vamos ver o que acontece.

USER Function MyClient()
While MsgYesNo("Envia um processamento a um JOB ?")
  If IpcGo("MYJOB_IPC","U_MYTASK",1,10)
    MsgInfo("Processamento enviado.")
  Else
    MsgSTop("Nao foi possivel enviar a requisicao. Nao há jobs disponiveis.")
  Endif
Enddo
Return

USER FUNCTION MYJOB1( cMsg1, cMsg2 ) 
Local cFN,p1,p2

conout("Thread ["+cValToChar(ThreadID())+"] iniciando ... ")

While !killapp()
  cFN := NIL 
  p1 := NIL
  p2 := NIL 
  conout("Thread ["+cValToChar(ThreadID())+"] Aguardando um chamado ... ")
  If IpcWaitEx("MYJOB_IPC",5000,@cFN,@p1,@p2)
    conout("Thread ["+cValToChar(ThreadID())+"] Executando " + p1)
    &cFN.(p1,p2)
  Endif
Enddo

conout("Thread ["+cValToChar(ThreadID())+"] saindo ... ")
return


USER Function MYTASK(p1,p2)
Local nX
conout("Thread ["+cValToChar(ThreadID())+"] --- Inicio da tarefa ...")
For nX := p1 TO p2 
  conout("Thread ["+cValToChar(ThreadID())+"] --- Contando "+cValToChar(nX)+" ( "+cValToChar(p1)+" a "+cValToChar(p2)+" )")
  Sleep(1000)
Next
conout("Thread ["+cValToChar(ThreadID())+"] --- Final da tarefa ...")
Return

Log de execução

Thread [9664] iniciando ...
Thread [9664] Aguardando um chamado ...
Thread [7368] iniciando ...
Thread [7368] Aguardando um chamado ...
Thread [11852] iniciando ...
Thread [11852] Aguardando um chamado ...
Thread [9664] Aguardando um chamado ...
Thread [7368] Aguardando um chamado ...
Thread [11852] Aguardando um chamado ...
Thread [9664] Executando U_MYTASK
Thread [9664] --- Inicio da tarefa ...
Thread [9664] --- Contando 1 ( 1 a 10 )
Thread [9664] --- Contando 2 ( 1 a 10 )
Thread [9664] --- Contando 3 ( 1 a 10 )
Thread [7368] Executando U_MYTASK
Thread [7368] --- Inicio da tarefa ...
Thread [7368] --- Contando 1 ( 1 a 10 )
Thread [9664] --- Contando 4 ( 1 a 10 )
Thread [7368] --- Contando 2 ( 1 a 10 )
Thread [9664] --- Contando 5 ( 1 a 10 )
Thread [11852] Aguardando um chamado ...
Thread [7368] --- Contando 3 ( 1 a 10 )
Thread [9664] --- Contando 6 ( 1 a 10 )
Thread [11852] Executando U_MYTASK
Thread [11852] --- Inicio da tarefa ...
Thread [11852] --- Contando 1 ( 1 a 10 )
Thread [7368] --- Contando 4 ( 1 a 10 )
Thread [9664] --- Contando 7 ( 1 a 10 )
Thread [11852] --- Contando 2 ( 1 a 10 )
Thread [7368] --- Contando 5 ( 1 a 10 )
Thread [9664] --- Contando 8 ( 1 a 10 )
Thread [11852] --- Contando 3 ( 1 a 10 )
Thread [7368] --- Contando 6 ( 1 a 10 )
Thread [9664] --- Contando 9 ( 1 a 10 )
Thread [11852] --- Contando 4 ( 1 a 10 )
Thread [7368] --- Contando 7 ( 1 a 10 )
Thread [9664] --- Contando 10 ( 1 a 10 )
Thread [11852] --- Contando 5 ( 1 a 10 )
Thread [7368] --- Contando 8 ( 1 a 10 )
Thread [9664] --- Final da tarefa ...
Thread [9664] Aguardando um chamado ...
Thread [11852] --- Contando 6 ( 1 a 10 )
Thread [7368] --- Contando 9 ( 1 a 10 )
Thread [11852] --- Contando 7 ( 1 a 10 )
Thread [7368] --- Contando 10 ( 1 a 10 )
Thread [11852] --- Contando 8 ( 1 a 10 )
Thread [7368] --- Final da tarefa ...
Thread [7368] Aguardando um chamado ...
Thread [11852] --- Contando 9 ( 1 a 10 )
Thread [11852] --- Contando 10 ( 1 a 10 )
Thread [9664] Aguardando um chamado ...
Thread [11852] --- Final da tarefa ...
Thread [11852] Aguardando um chamado ...
Thread [7368] Aguardando um chamado ...
Thread [9664] Aguardando um chamado ...
Thread [11852] Aguardando um chamado ...
Thread [7368] Aguardando um chamado ...
Thread [9664] Aguardando um chamado ...
Thread [11852] Aguardando um chamado ...

O programa cliente enviou três requisições com sucesso, onde cada uma ocupou um dos processos em espera. Cada processo deve ficar ocupado por 10 segundos, logo se o programa cliente tentou enviar uma quarta requisição em menos de 10 segundos depois de ter enviado a primeira, ela não vaio encontrar nenhum JOB aguardando em IPCWAITEX, e a função IPCGO() retorna .F. pois não conseguiu enviar a chamada para frente.

O resultado é lindo, a função cliente apenas disparou um IPCGO() com o que precisa ser feito, e a função foi executada em um job pré-configurado, sem que o cliente precisasse esperar pelo retorno ou pelo fim do processamento. Isso para um teste ou uma prova de conceito, até funciona. Mas no mundo real, normalmente é necessário saber se aquele processamento foi concluído com sucesso, ou se aconteceu alguma coisa durante o processamento, e o que aconteceu.

Fatiando processos

Mas, por hora vamos focar em um ponto importante: — Você não precisa e nem deve preocupar-se com “quantas instancias” serão colocadas no ar para dividir ou dimensionar seu trabalho.

Sim, é isso mesmo. É natural você pensar imediatamente em registros por processos e número de processos, por exemplo: Se eu tenho 50 mil títulos para serem processados, e vou subir 10 jobs, então eu vou mandar 5 mil títulos para cada JOB … Porém isso traz algumas consequências desagradáveis.

A primeira delas é: Mesmo que a velocidade média do processamento de um título seja igual entre os processos, cada job vai ficar no ar ocupado por muito tempo processando um lote muito grande de informações. Qualquer variação no tempo de processamento pode fazer um job terminar a sua parte muito rápido, enquanto outros jobs podem demorar mais tempo para terminar a sua parte … e o seu processamento somente está COMPLETO quando todos os JOBS terminarem. Por exemplo, imagina que a média de processamento de um título seja entre 1,5 e 1 segundo. Isto significa que, um processamento de 5 mil títulos pode demorar de 41 a 82 minutos.

Para evitar isso, o ideal é definir um lote de títulos de um determinado tamanho, de modo que o processamento de um lote demore de 30 a 120 segundos. Depois, você deve subir uma quantidade de jobs no ambiente que não prejudiquem o desempenho deste servidor de aplicação, e esse numero varia de ambiente para ambiente. Mantendo cada JOB ocupado por um período mais curto de tempo, eles vão passar mais tempo rodando em paralelo.

Considerando o caso anterior, vamos simplificar o exemplo dividindo 10 mil registros em 2 jobs, cada um deles já pegou a sua “faixa” de registros para processar … embora os dois tenham iniciados juntos, um deles terminou a parte dele em 45 minutos, e o outro demorou mais 20 minutos, e o processo “pai” teve que esperar todos os processos filho terminarem para obter um resultado. Criando um lote de processamento de, por exemplo 60 títulos por vez, cada job ficará alocado de 30 segundos a um minuto para processar o seu lote, ficando disponível novamente para pegar o próximo lote. Neste caso, os dois jobs ficariam quase todo o tempo rodando em paralelo, ficando um deles no máximo 30 segundos “sozinho” terminando de processar o ultimo lote.

Controlando a execução

Voltando um pouco antes da parte de “Fatiar” um processo, é importante saber se todas as requisicoes enviadas para processamento foram realmente processadas com sucesso. Existem várias formas de se fazer isso, inclusive durante a execução do processo, ou no final dele.

Por exemplo, sabendo que cada processo dedicado deve pegar um lote de 30 títulos — fixos ou determinados por uma faixa de intervalo — o programa principal (pai) deste processamento pode criar uma tabela registrando todas as requisições que foram distribuídas, e cada programa que finaliza o processamento de uma requisição, atualiza o Flag dela na tabela de controle. Assim, no final do processo, quando todos os jobs tiverem terminado de processar e não houver nada mais pendente, o LOG de operação deve ser verificado para ver se está tudo em ordem

Inclusive, existem erros graves que não são interceptados por rotinas de tratamento e recuperação de erro, que geram apenas um log local. Quando pensamos em múltiplos jobs, temos estes pontos a considerar.

A função STARTJOB

Utilizando a função STARTJOB() do AdvPL, podemos subir uma instância para rodar uma função qualquer em tempo de execução. O novo processo será colocado no ar no mesmo serviço do Protheus em uso pelo processo “pai” que iniciou o JOB. Para subir múltiplas instâncias, basta fazer um FOR .. NEXT, e podemos passar nenhum, um ou vários parâmetros para a função a ser executada. Veja a documentação da função na TDN, nas referências no final do post.

Existe JOB remoto ?

Sim, existe. Quando usamos uma conexão RPC nativa do AdvPL entre serviços do Protheus Server, podemos iniciar um JOB no servidor alvo da conexão RPC. Logo, uma rotina em AdvPL, onde você possa mapear e fazer RPC para os serviços do Protheus que devam fazer parte de um grande processamento, seria capaz de subir jobs remotos via RPC, e através do RPC, fazer uma distribuição de tarefas para os jobs dinamicamente, usando por exemplo um algoritmo round robin — pega a lista de objetos RPC conectados, passa uma tarefa para um job do primeiro servidor, vai para o próximo servidor, se a tarefa anterior foi passada com sucesso pega uma nova, senão tenta passar para o servidor atual, acabou a lista de conexões, volta para a primeira, mantem dentro desse loop até acabarem as tarefas, coloca um SLEEP de um segundo caso ele tenha varrido todo o array de objetos RPC e não foi encontrado nenhum job livre, espera os jobs remotos terminarem as suas tarefas, e finaliza a rotina.

Existem outros tipos de JOB ?

Sim, também configurados no appserver.ini, existem JOBS que são configurados como Pools de Working Threads, com ou sem inicialização automatica pela seção ONSTART. Por exemplo, o JOB TYPE=WEBEX, utilizado para Working Threads de Portais, WEBEX (AdvPL ASP) e WebServices.

Scheduler do ERP

Vale lembrar que o ERP Microsiga possui uma funcionalidade chamada “Scheduler”, que permite a execução programa de tarefas agendadas, com muito mais detalhes e controles. O objetivos dos exemplos deste post são direcionados a mostrar formas mais simples e nativas da linguagem AdvPL e do TOTVS Application Server de executar processos em JOB.

Conclusão

Cada linguagem de programação oferece um nível de flexibilidade para criarmos uma solução, o segredo está em buscar o uso de alternativas simples, e usar as alternativas mais complexas onde elas são necessárias, buscando implementá-las também de forma simples. Eu acredito que esse é o desafio constante do desenvolvedor de software.

Desejo a todos(as) TERABYTES de SUCESSO 😀

Referências

ONSTART 
IPCWAITEX
IPCGO 
Round Robin

Escalabilidade e Performance – Segredos

Introdução

Outo dia li um post muito interessante, onde o autor menciona alguns “segredos” para uma aplicação escalável, com algumas técnicas comuns utilizadas em aplicações WEB ( Vide post original nas referências no final do Post). Resolvi me basear nele para exemplificar o que cada um dos tópicos elencados poderia agregar ao sistema, e levantar algumas questões de aplicabilidade de cada um, dentro do ambiente AdvPL.

“STATELESS”

“Se você quer um sistema ou um serviço escalável, com certeza você quer que todas as requisições para este serviço sejam stateless.
Mas por que? Simplesmente por que caso em um futuro próximo você precise rodar a sua aplicação em um cluster, você não prende um cliente a um nó do cluster, cada requisição pode ir para o nó do cluster que estiver com a menor carga naquele momento, fazendo com que o tempo de resposta aquela requisição seja o menor possível, mantendo o nó do cluster que vai atender a esta requisição ocupado o menor tempo possível.” (Sobre Código: Os 5 segredos para um sistema altamente escalável, por Rodrigo Urubatan)

Esta afirmação casa com os princípios de escalabilidade e performance já mencionados em posts anteriores, mais especificamente sobre uma regra que diz “não estabeleça afinidade”. Basicamente, o “stateless” significa que uma requisição de processamento não deve depender de um estado específico de uma requisição anterior. Isto significa que o agente de processamento da requisição não deve reter nenhuma informação da requisição anterior, o que torna possível distribuir uma requisição de processamento para qualquer agente disponível no cluster.

Isto pode ser aplicado a diversos tipos de processamento, mas normalmente requer que a aplicação seja desenhada para trabalhar desta forma. Por exemplo, hoje quando desenvolvemos uma aplicação AdvPL para ser acessada através da interface do SmartClient, a aplicação trabalha com uma conexão TCP persistente, onde o programa AdvPL responsável pelas instruções de interface, também é responsável por montar o ambiente de execução e executar as aplicações de processamento e acesso a SGDB e meta-dados no mesmo processo.

Por exemplo, uma inclusão de um cliente através de um programa de interface SmartClient no AdvPL, é iniciada ao acionarmos um botão na janela, que executa uma função do código AdvPL pelo Application Server dentro do próprio processo, que acessa o SGDB e executa efetivamente a inserção e demais integrações e gatilhos sistêmicos relacionados (pontos de entrada do ERP), mantendo a interface em “espera” enquanto o processo é executado.

Para esta operação ser executada em um outro nó do cluster, a aplicação precisaria ser desenhada para, no momento de submeter a inclusão de um cliente, a função deveria gerar um request de inclusão de cliente, e despachá-la para um agente de distribuição de processos, que iria alocar um processo em espera no cluster de processamento, para este processo dedicado realizar o acesso ao banco e concluir a operação, liberando a interface para iniciar uma nova inclusão ou outra operação. A dificuldade de lidar com estes eventos é desenvolver um mecanismo resiliente, que permita gerar informações e um “BackLog” do que foi feito, pois procurar um erro de processamento ou comportamento em um ambiente assim, sem rastreabilidade das operações, não será uma tarefa fácil.

Um WebService por natureza é concebido como STATELESS. Existem algumas tecnologias que permitem criar afinidade e persistência em WebServices, porém isto foge ao objetivo principal da natureza das requisições e dos serviços que a implementação original se propõe a fazer. Se uma aplicação em WebServices precisa de afinidade e persistência, o uso de WebServices não parece uma boa escolha. Nem toda a operação pode ser totalmente assíncrona, mas muitas etapas de processo compensam o custo da transformação, revertida em escalabilidade.

“REQUEST RESPONSE TIME” (RRT)

Partindo de um ambiente de serviços distribuídos, onde vários processos podem realizar operações específicas, é fundamental que as requisições importantes ( ou prioritárias) sejam atendidas com o menor tempo de resposta possível. É o mesmo princípio dos caixas de um banco: Quando temos mais clientes do que caixas disponíveis, é formada uma fila, e o primeiro caixa que terminar o atendimento torna-se disponível e chama o primeiro da fila.

Se os caixas forem lentos, quando começar uma fila, as pessoas desta fila vão demorar para serem atendidas. Então, ou você coloca mais caixas, ou você melhora o desempenho dos caixas. Pensando por exemplo na priorização de atendimento a idosos, portadores de necessidades especiais, gestantes e mães com crianças de colo, são criadas duas filas de atendimento, vamos chamá-las de “normal” e “prioritária”. Todos os caixas podem atender a qualquer pessoa da fila, porém ocorre um escalonamento do atendimento.

Quanto mais rápido e eficiente for o atendimento de um caixa, mais clientes por hora ele será capaz de atender. A mesma regra vale para requisições de processamento (SOA/RPC/SOAP/REST). Se você tem um volume diário de X requisições, sujeitas a variações ou picos, e cada caixa aberto consegue atender em média N requisições por minuto, e você tem Y caixas abertos, se todas as X requisições chegarem ao mesmo tempo, as Y primeiras são atendidas imediatamente, e as demais entram na fila. Com todos os caixas atendendo, se ninguém mais entrar na fila, voce vai manter todos os caixas ocupados e trabalhando por M minutos até não ter mais ninguém na fila.

Pegamos o total de requisições (ou clientes) X , dividimos pelo numero de processos disponíveis (ou caixas abertos) Y, e temos uma média de quantas requisições devem ser processadas por cada processo (quantos atendimentos cada caixa deve fazer). Multiplicamos este número pelo tempo médio de processamento da requisição (tempo de atendimento), e teremos uma ideia de quanto tempo será necessário para eliminar a fila. Se chegam 20 requisições, e existem 4 processos disponíveis, cada um deles vai realizar 5 processamentos. Se cada processamento tem um tempo médio de 3 segundos, cada processo vai permanecer ocupado processando por 5×3 = 15 segundos. Se neste meio tempo não chegar mais ninguém na fila, em 15 segundos a fila acaba. Um caixa trabalhando sem parar em um minuto (60 segundos) , consegue atender (sem pausa ou intervalo) 60/3 = 20 requisições em um minuto. Como temos 4 processos, todos juntos conseguem atender 20 x 4 = 80 requisições por minuto.

Se dobrarmos o número de processos (8), conseguimos atender 160 requisições por minuto. Se o tempo de cada processamento (3 s.) diminuir pela metade (1,5 s.), conseguimos atender a 160 requisições por minuto com apenas 4 processos dedicados. Muitas vezes não temos como aumentar mais os processos, por limites de sistema ou esgotamento de recursos, então quanto mais leve e rápido for cada processo, melhor.

Usando AdvPL, existe flexibilidade em se criar pools de processos nativos, e dependendo de sua carga e consumo de memória, colocar múltiplos processos para atender requisições simultâneas. Porém, devem ser observados se os processos aderem aos princípios de paralelismo, senão a aplicação vai jogar memória fora e não vai escalar.

“CACHE”

Lembrando de uma das regras básicas de desempenho de tráfego de informações, buscar uma informação pela rede costuma ser mais lento que ler do disco, e pegar ela da memória é mais rápido do que ler do disco. Mas como não dá pra colocar tudo na memória, e estamos falando de paralelismo em sistemas distribuídos, usar um CACHE das informações mais repetidamente lidas na memória de cada máquina é uma forma bem eficiente de reduzir o RRT. Mas, lembre-se: Não é tudo que voce precisa colocar em um cache … apenas as informações mais acessadas, e com baixa volatilidade. Usar um cache local na memória do servidor, compartilhado pelos demais processos, pode economizar muitas idas e vindas de requisições pela rede.

Um exemplo disso se aplica mais facilmente a uma aplicação WEB dinâmica, onde uma página de promoções acessa o SGBD para mostrar os produtos em promoção. Se as promoções são alteradas diariamente, vale a pena fazer um cache dessa página, e removê-la do cache apenas quando houver alteração das promoções. Para um ERP, um cache interessante seria os meta-dados de algumas tabelas. Se boa parte dos usuários do sistema abre as mesmas tabelas e telas, a leitura destas definições poderiam ser colocadas em um cache na máquina dos slaves, alimentado sob demanda e com uma validade em tempo pré-determinada. O primeiro usuário que abre uma tela de cadastro de produtos ou pedidos alimenta o cache, e todos os demais usuários conectados em serviços naquela máquina pegam as definições do cache local, muito mais rápido, e sem consumir recurso de rede.

Existem meios de se criar caches em AdvPL, usando por exemplo variáveis globais, ou mesmo utilizar um cache de mercado (MemCacheDB, por exemplo), desde que seja escrito um Client em AdvPL que use a api client de Socket’s do Advpl (Classe tSocketClient) para fazer a comunicação. RPC e variáveis globais podem fazer uma bela dupla, mas vão precisar de um certo nível de controle e gerenciamento implementado manualmente, mas isto não inviabiliza esta alternativa.

“REMOTE DATA”

Quando falamos de aplicação Client-Server em Cluster, devemos ter em mente que as informações comuns a determinados processos precisam estar em um lugar onde todos os nós de processamento tenham acesso. Parte da premissa de não criar afinidade. Fica mais fácil citar um exemplo de servidor WEB com Upload de imagens, onde você grava a imagem em uma pasta do servidor: Ao invés de compartilhar esta pasta pelo sistema de arquivos, é possível criar um servidor dedicado a conteúdo estático, e fazer um mecanismo de cache ou réplica nos nós, se ou quando necessário.

A abordagem do AdvPL parte da premissa que cada serviço slave tenha acesso ao RootPath do ambiente, compartilhado pelo sistema operacional do equipamento. Definitivamente um excesso de acessos concorrentes pode prejudicar uma escalabilidade.

“REVERSE PROXY”

O exemplo de proxy reverso também se aplica mais em escalabilidade de ambientes WEB. Ele serve de “Front-End” da sua aplicação, permite a utilização de técnicas de Cache, mascaramento de IP, “esconde” a sua infra-estrutura interna, entre outas funcionalidades. O detalhe importante disso é que um Proxy Reverso, pelo fato de estar “na frente” de tudo, ele passa a ser um SPOF (Single Point of Failure). Se o Proxy Reverso “morrer”, sua aplicação morre junto.

Para aplicações SOA, uma alternativa interessante é usar o já mencionado “controlador” de requisições. Cada nó do cluster pergunta para um controlador de serviço onde está o nó mais próximo disponível, e cada serviço de controle fala com os demais. Assim, você pode deixar serviços ativos em vários nós, e caso um nó saia do ar, o controlador direciona a requisição para outro serviço disponível.

Resumo geral dos conceitos

Cada uma das técnicas ou “segredos” abordados exige que a aplicação seja desenvolvida para aderir a estes paradigmas. Uma aplicação Client-Server monolítica e de conexão persistente não se encaixaria neste modelo sem uma grande refatoração. É mais complicado gerenciar múltiplos recursos em múltiplos equipamentos, é mais complicado desenvolver pensando nestas características, a administração deste tipo de ambiente requer instrumentações, alertas e procedimentos da forma mais automatizada possível. Imagine você ter que conectar e abrir cada uma das máquinas para procurar por um LOG de erro específico, ou tentar descobrir por que ou como uma informação inconsistente foi parar na base de dados. Cada camada precisa estar “cercada” por mecanismos que visem minimizar inconsistências e evitar que elas se propaguem, além de permitir rastrear os caminhos da informação.

No AdvPL são fornecidos recursos como os WebServices (SOAP e REST), RPC nativo entre servidores, IPC entre processos da mesma instância de Application Server. A junção de todos eles pode tornar real e segura uma implementação desta natureza. Agrupando requisições por funcionalidade, e tendo um painel de gerenciamento e monitoramento dos recursos, você consegue medir quanto “custa” cada pedaço do sistema em consumo de recursos, e consegue lidar melhor com estimativas de crescimento, identificação de pontos críticos de melhoria, tornando assim possível a tão sonhada “escalabilidade horizontal” — em grande estilo.

Conclusão

A automatização de processos de Build e testes têm se mostrado muito efetiva em cenários de desenvolvimento e roll-out de produtos. Eu acredito que já esteja mais que na hora de criar mecanismos assistidos de deploy e mecanismos nativos de painéis de gestão de configuração e ambiente. Uma vez estabelecida a confiabilidade nos mecanismos de monitoramento, ações podem ser programadas e realizadas automaticamente, minimizando impactos e reduzindo os riscos de ter uma indisponibilidade total do ambiente computacional.

Criar uma aplicação que desde a sua concepção já considere estes pontos, será tão trabalhosa quando migrar uma aplicação que conta com comportamentos sistêmicos persistentes, mas ambos os esforços devem compensar o potencial de expansão que pode ser adquirido com esta abordagem.

Novamente, agradeço a audiência, e desejo a todos TERABYTES de sucesso 😀

Até o próximo post, pessoal 😉

Referências

Sobre Código: Os 5 segredos para um sistema altamente escalável, por Rodrigo Urubatan. Acessado em 06/12/2015 em <http://sobrecodigo.com/os-4-segredos-para-um-sistema-altamente-escalavel/>.

Stateless protocol. (2015, August 20). In Wikipedia, The Free Encyclopedia. Retrieved 01:33, October 25, 2015, from https://en.wikipedia.org/w/index.php?title=Stateless_protocol&oldid=677029705

Acelerando o AdvPL – Lendo arquivos TXT (ERRATA)

Pessoal,

No post “Acelerando o AdvPL – Lendo arquivos TXT”, foi corrigido um erro que causava um mau comportamento da rotina,  fazendo a leitura de linhas “inconsistentes”. Na chamada da função RAT(), o primeiro parâmetro deve ser a string a ser procurada, e o segundo parâmetro deve ser a string onde a primeira deve ser procurada. A passagem de parâmetros estava ao contrário, fazendo com que a quebra de linha no final de um bloco lido fosse identificada erroneamente, fazendo a rotina retornar linhas com quebras inexistentes. O código-fonte do post original já foi corrigido, segue abaixo o detalhe da correção.

Antes da correção (fonte incorreto)

// Determina a ultima quebra
nRPos := Rat(cBuffer,::cFSep)

Após a correção, o código deve ficar assim:

// Determina a ultima quebra
nRPos := Rat(::cFSep,cBuffer)

Estava trabalhando em um próximo post sobre escalabilidade e performance, e aproveitei a classe para ilustrar um processamento. Quando executei a rotina, percebi que as quebras de linha estavam inconsistentes, pois foram retornadas mais linhas do que eu havia inserido.

Escalabilidade e Performance – Parelelismo – Parte 01

Introdução

Em posts anteriores sobre escalabilidade e desempenho, foram frisados vários pontos e técnicas que devem ser levadas em conta no momento de desenvolver uma aplicação partindo das premissas de escalabilidade horizontal e desempenho. Agora vamos trazer isso para um contexto real dentro do AdvPL, explorando uma análise de um cenário muito comum: Processamento de grandes quantidades de dados.

Cenário proposto

Imagine um cenário onde você precisa realizar um processamento de um arquivo TXT (ou CSV), que pode ter muitos MB … O ponto importante é que você pode receber vários arquivos em um curto espaço de tempo, e estes arquivos precisam ser processados e alimentar tabelas do sistema da forma mais rápida possível. Nesta abordagem, vamos abordar a análise de caso e os pontos importantes a considerar.

Fase 01 – Determinar origem dos arquivos

A primeira coisa a pensar é onde estarão estes arquivos. Para eles serem processados mais rapidamente, eles precisam estar pelo menos em uma pasta no RootPath do ambiente. Se estes arquivos vêm de fora do sistema, e você tem que pegá-los em algum lugar fora da sua rede, a melhor alternativa de tráfego é um FTP. O FTP é um protocolo criado especificamente para transferência de arquivos. Nada impede de você usar um webservice para transferir o arquivo em pedaços, mas isto vai trazer um custo (overhead) do HTTP e do XML-SOAP. Se os arquivos estão na própria rede interna, eles podem ser copiados via sistema de arquivos do sistema operacional mesmo, com compartilhamento de rede, numa pasta dentro do RootPath, mas isto vai ser tão rápido quanto FTP, a menos que você utilize um comando mais robusto de cópia, como o ROBOCOPY.

Fase 02 – Determinar pontos de paralelismo

Os arquivos já estão na pasta. Agora, vamos verificar se é possível aplicar algum tipo de paralelismo neste processo. Se os arquivos precisam impreterivelmente obedecer a ordem de chegada, onde cada arquivo precisa ser processado por inteiro antes do próximo arquivo iniciar o processo, uma parte boa do paralelismo já foi perdida … Se os arquivos não tem ordem de processamento, isto é, cada um pode ser processado aleatoriamente, você poderia usar múltiplos JOBS , onde cada um processaria um arquivo inteiro por vez.

Mas nem tudo está perdido … Se dentro de um grande arquivo, as linhas não tiverem ordem de processamento, isto é, cada linha é uma unidade independente de informação, e você não precisa necessariamente ter terminado de processar a linha 999 para processar a linha 1000, … ainda temos uma ótima possibilidade de paralelismo. Vamos assumir que este cenário é possível no nosso exemplo.

Certo, então para aproveitar o máximo possível de paralelismo, eu posso quebrar o arquivo em vários pedaços, e colocar um JOB para processar cada pedaço do arquivo. A pergunta a ser feita agora é: O processamento de uma determinada linha vai exigir algum recurso exclusivo durante o processamento, que também pode ser ou será efetivamente necessário para processar outra linha?

Por exemplo, de cada linha a ser processada do arquivo precisar atualizar um mesmo registro da base de dados, caso duas linhas distintas, sendo processadas pelos jobs precisem fazer um lock de alteração de um mesmo registro de uma tabela no SGBD, fatalmente um dos processos ficará esperando o outro terminar a transação e soltar o lock para conseguir fazer o seu trabalho. Isto implica em uma serialização do processamento neste ponto.

Se as chances disso ocorrer são pequenas durante o processamento, a maioria dos processos somente vai sofrer uma contenção ou serialização em pontos isolados, ainda temos muito a ganhar com processos em paralelo. Agora, se grandes blocos de registros correm o risco de colidir, mesmo assim nem tudo está perdido. No momento de quebrar o arquivo em pedaços, cada pedaço deve agrupar o máximo de linhas que utilizem o mesmo registro, para que um pedaço do arquivo evite concorrer com os demais.

Fase 03 – Quebrando o arquivo

Agora, vamos “quebrar o arquivo”. Vamos pensar bem nesta etapa, afinal existem muitas formas de fazer isso. Precisamos encontrar aquela que melhor se adequa a nossa necessidade, consumindo um mínimo de recursos possível. Se o consumo de um recurso for inevitável, precisamos encontrar a forma mais eficiente de fazê-lo.

Normalmente subimos Jobs ou Threads de processamento na mesma instância do Application Server onde estamos rodando o nosso programa. Porém, subir 2, 3 ou mais threads no mesmo Application Server, quando temos uma máquina com 8 cores HT — como um servidor Intel com processador DUAL-Quadricore HT, é mais interessante subir alguns jobs de processamento em mais de um Application Server. Isto pode exigir alguns controles adicionais, mas os resultados costumam ser muito interessantes.

O lugar onde subir estes Jobs também é importante. Na máquina onde está a unidade de disco com o ambiente do ERP, a leitura de arquivos é feita direto pelo FileSystem do Sistema Operacional, é muito mais rápido do que ler pela rede. Porém, normalmente nesta máquina utilizamos um c-Tree Server, para compartilhar os meta-dados (Dicionários, SXS) do ERP com todos os demais serviços. Colocar muito processamento nesta máquina pode usar muitos recursos dela, e prejudicar os demais processos.

A alternativa mais flexível que evita este risco, é poder subir os Jobs em qualquer um dos serviços de qualquer uma das máquinas disponíveis no parque de máquinas que rodam os Application Server’s. Para isso, fatalmente teremos que usar a rede. Porém, se usarmos ela com sabedoria, podemos minimizar impactos. Vamos partir para esta alternativa.

Fase 04 – Multiplos processos

Existem várias formas de colocarmos JOBS dedicados para realizar um trabalho em vários serviços. Podemos fazer a nossa aplicação iniciar os Jobs de processamento apenas quando necessário, usando por exemplo o RPC do AdvPL, onde no programa principal enumeramos os IPs e Portas dos Application Server’s que serão endereçados para esta atividade, e através de RPC subimos um ou mais Jobs em cada um, usando por exemplo a função StartJob(), ou podemos deixar em cada um dos serviços de processamento um ou mais jobs no ar, inicializados na subida de cada Application Server usando a seção [ONSTART] do appserver.ini, e depois fazendo RPC para cada serviço e distribuir os trabalhos usando IPC (Internal Procedure Call) do AdvPL.

Subir um pedaço grande inteiro do arquivo na memória inicialmente pode parecer uma boa alternativa, porém lembre-se que em se tratando de processos paralelos, várias juntas podem consumir muita memória, E , se muita memória é ocupada e endereçada, os processos de lidar com esta memória podem ficar mais lentos do que lidar com pedaços menores. Nas minhas experiências, buffers de 8 KB ou 16K dão conta do recado, fazendo um acesso eficiente em disco, e gerando um pedaço de dados que não vai “doer” na memória dos processos.

A parte importante aqui é: Como quebrar um arquivo grande de forma eficiente, e distribuir seus pedaços para processamento? Uma primeira idéia que me veio na cabeça foi fazer com que o programa principal de processamento abrisse a tabela e fizesse a leitura de várias linhas. Após acumular na memória um bloco de linhas, com o mínimo de processamento possível (por exemplo colocando as linhas dentro de um array), este bloco de linhas pode ser enviado por RPC para um dos slaves, e disparar um IpcGo() para uma das threads disponíveis no slave onde o RPC está conectado.

Desta forma, ao invés de mandar linha por linha em cada requisição, onde em cada uma você vai ter um pacote de rede muito pequeno, ao agrupar um número de linhas que chegasse perto de 64 KB de dados, enviar este bloco de uma vez, em um único evento de envio de rede por RPC, para o Slave “da vez”, ele receberia este pacote de uma vez só, isso aproveitaria bem melhor a banda de rede, e o processo alocado naquele Application Server que recebeu este pacote estaria dedicado a processar todas as linhas recebidas naquela solicitação.

Fase 05 – Sincronização de Processos

Parece lindo, mas ainda têm 2 problemas: Partindo de um ambiente com 4 serviços de Protheus, com 2 threads dedicadas em cada um, você teria um total de 8 threads espalhadas. Fazendo um Round-robin entre os serviços, a primeira requisição iria para a T1S1 (primeira thread do serviço 1), a próxima para T1S2, depois T1S3, T1S4, T2S1, T2S2, T2S3, T2S4, e agora as 8 threads estão ocupadas processando cada uma 64 KB de linhas.

Quando você for enviar a requisição 09, ela vai novamente para o serviço 1 … se nenhuma das threads do serviço 1 terminou de processar o pacote anterior, você gastou 64 KB da banda de rede trafegando um pacotão de coisas, para um servidor ocupado … Então você tenta enviar para o servidor 2, gasta mais banda de rede, e… nenhuma thread livre …. Mesmo que você crie uma mensagem de “consulta”, onde você não passasse os dados, apenas perguntasse se tem threads livres naquele servidor, você ficaria metralhando a rede com pacotes pequenos em todos os serviços mapeados para o processo, até que um deles iria responder “estou livre”, para você então mandar para ele mais um pacote de linhas.

Este pequeno percalço pode ser resolvido com um mecanismo chamado de “CallBack”. Nada impede que o seu Job possa fazer um RPC de volta para você, para avisar que um determinado pedaço foi processado. Com isso você pode usar o RPC com IPC em “mão dupla”, com dois pares de conexões. Quando o seu programa principal enviar as 8 requisições, ele entra em um loop de espera por uma mensagem de IPC. Cada JOB alocado precisa receber no momento da alocação um IP e porta e ambiente para conectar-se de volta, para notificar os estados de processamento. Quando um JOB terminar de processar um pacote, ele manda via RPC um sinal de IPC para avisar que ele terminou, informando “quem ele é” na mensagem. Como o seu processo principal vai estar esperando receber uma notificação via IPC, assim que ela chegar, o seu programa principal pega o próximo pacote de linhas e manda para aquela thread via RPC+IPC, pois assim que esta thread enviar a notificação de retorno, ela vai entrar em modo de espera de IPC para uma nova solicitação de processamento.

Este sincronismo também pode ser de mão única … ao invés do seu Job principal fazer um “push” das requisições de processamento, ele pode acionar uma vez os jobs dedicados para serem alocados para este processo, onde o job dedicado imediatamente faz o callback, e passa a “pedir” requisições de processamento, e notificar o programa principal pelo mesmo canal a cada pacote processado.

Existem outras formas de sincronismo, como por exemplo usar o banco de dados. O programa principal cria uma tabela para fins temporários no SGDB, e alimenta esta tabela com os pacotes a serem processados. Porém, se este mecanismo for utilizado, você acaba consumindo tempo e recursos para popular uma tabela grande no SGDB, e cada processo faz uma busca indexada na tabela por um pacote que ainda não foi processado. Ao conseguir locar o registro, o programa de processamento deve mudar o status para “em processamento” e manter o lock até o final do processo. No caso, o programa principal vai alimentando a tabela, enquanto os jobs dedicados vão pegando os registros não processados e fazendo suas tarefas. Quando o programa principal terminar de popular a tabela, ele passa a verificar quais registros ainda estão em processamento, aguardando todos ficarem prontos, e verificando se os jobs de processamento que pegaram os registros ainda estão no ar. NO final das contas, acaba sendo um mecanismo tão complexo quanto o sincronismo “online”, ainda com a desvantagem de colocar o SGDB no meio do caminho.

Fase 06 – Tratamentos de erro

Agora o bicho pega … Imaginar que tudo vai funcionar como um relógio é o mundo lindo … mas e se um job cair, e se a resposta não vir, e se todos os envios falharem, como é que o programa principal fica sabendo se aconteceu algo horrível ? Essa parte do controle é realmente um “parto” de ser feita … existem vários caminhos, mas normalmente os mais fáceis são os mais sujeitos a erros do tipo “falso-positivo” ou “falso-negativo”. Uma das formas mais interessantes de controle é ter um mecanismo confiável para saber se um determinado JOB que recebeu uma requisição ainda está no ar, e criar no programa principal um mecanismo de registro de envios, para dar baixa de cada pacote enviado conforme os eventos de retorno são recebidos, onde deve constar neste mecanismo o momento de envio, para que se um pacote começar a demorar muito, você possa consultar se o job que recebeu aquele pacote ainda está no ar — apenas está demorando um pouco mais — ou se ele saiu do ar — normalmente por erro.

Com estes processos sincronizados e as métricas definidas, o programa principal que inicia o processamento pode, após garantir que um pacote de linhas foi enviado e o job de destino “caiu” sem terminar o processo, você pode direcionar este pacote para outra thread em outro serviço, evitando assim ter que reiniciar o trabalho.

Vale lembrar que cada processo deve ter um transacionamento, que deve ser mantido aberto pelo menor tempo possível, e que os programas de processamento não vão ter “interface”, não será possível (não de maneira simples) perguntar pro operador do sistema o que fazer ou dar uma opção de múltipla escolha para um determinado tratamento dentro de uma linha em um job. Normalmente você trata no programa os cenários possíveis, e qualquer coisa que não estiver de acordo, você pode rejeitar o processamento e gerar um log do que foi recusado, para que sejam tomadas as providências quanto aquelas informações quando o processo terminar, e você puder rodar um reprocessamento apenas do que faltou, após arrumar os dados ou a condição não tratada.

Outras alternativas

Se, ao invés de usar Jobs dedicados, você subir vários jobs de acordo com a quantidade de pedaços da sua tabela, você pode subir um número de Jobs que pode gerar um colapso nos recursos do sistema, como CPU, rede, memória, disco, SGDB …. É mais saudável trabalhar com um número limitado de processos, e medir com testes se a quantidade pode ser aumentada sem comprometer a disponibilidade do resto do ambiente. Este processo é empírico, começa com um job em cada serviço, roda um processamento inteiro, verifica se algum recurso está sendo consumido excessivamente, aumenta alguns jobs, roda de novo …

Cuidados Especiais

Se você resolver subir os jobs sob demanda, usando um fator limitante, de qualquer modo você precisa de um mecanismo para saber se os jobs terminaram o que tinham que fazer, para você poder subir novos Jobs ou enviar requisições de processamento para um Job que já está livre.

É importante lembrar que, por mais abundantes que sejam os recursos, uma carga de processamento mal dimensionada pode derrubar o ambiente, gerando uma indisponibilidade geral de outros serviços. Um programa errado que começa a comer memória com farinha pode esgotar a memoria física de um equipamento, principalmente usando uma Build 64 Bits do Protheus, fazendo a máquina inteira entrar em “Swap” e paginação de memória em disco … eu já vi equipamentos literalmente entrarem em Negação de Serviço (DoS), onde não era possível sequer abrir um Terminal Services pra tentar parar os serviços.

Conclusão

Eu sei, neste post eu só “abrir o apetite”, e abri caminho para muitas reflexões importantes. No próximo post, vamos abordar cada uma destas etapas acompanhada de um exemplo prático 😉

Até o próximo post, pessoal ! Desejo a todos TERABYTES de sucesso 😀

Referências

Escalabilidade e Performance – Stored Procedures

Introdução

Em um tópico anterior sobre “Escalabilidade e performance – Técnicas”, um dos tópicos falava sobre Stored Procedures, inclusive sugerindo que seu uso deveria ser minimizado. Vamos entrar neste tema com um pouco mais de profundidade neste tópico. Vamos começar com o clone do tópico abordado, e esmiuçar ele dentro do contexto do AdvPL e Protheus.

Minimize o uso de Stored Procedures

Este é um ponto aberto a discussão, depende muito de cada caso. Não é uma regra geral, existem pontos em um sistema onde uma stored procedure realmente faz diferença, mas seu uso excessivo ou como regra geral para tudo impõe outros custos e consequências. O Princípio 1 diria: “use apenas stored procedures”. No entanto, esta decisão pode causar grandes problemas para o Princípio 2 devido à escalabilidade. As Stored procedures têm a vantagem de ser pré-compiladas, e não há nada mais perto de um dado no Banco de Dados.

Porém Bancos de Dados transacionais são especializados em quatro funções: Sort, Merge, gerência de Locks e Log. A gerência de lock é uma das tarefas mais críticas para a implementação de algoritmos distribuídos, e este é o real motivo de existirem poucos Bancos de Dados que possam implementar a escalabilidade horizontal. Se as máquinas de Banco de Dados têm dificuldade de escalar horizontalmente, ela é um recurso escasso e precioso. Temos então que otimizar seu uso para não consumir excessivamente seus recursos a ponto de onerar os demais processos do ambiente. Isto acaba adiando a necessidade de escalar horizontalmente o SGBD.

Abordando a questão de desempenho

Se o algoritmo para processamento de um grande grupo de informações pode ser escrito dentro de uma Stored Procedure no próprio Banco de Dados, esta alternativa tende fortemente a ser a mais performática. Num cenário onde o algoritmo é escrito usando um programa sendo executado dentro do servidor de aplicação da linguagem, cada processamento que dependa da leitura de grupos de dados e tenha como resultado a geração de novos dados vai ser onerado pelo tempo de rede de tráfego destes dados, na ida e na volta. Logo, com uma base de dados modelada adequadamente, e uma stored procedure bem construída, ela naturalmente será mais rápida do que um processamento que precisa trafegar os dados pra fora do SGDB e depois receba novos dados de fora.

Porém, este recurso não deve ser usado como solução mágica para tudo. Afinal, o SGDB vai processar uma Stored Procedure mais rápido, pois ele não vai esperar um processamento ser realizado “fora dele”, porém o SGDB vai arcar com o custo de ler, processar e gravar a nova informação gerada. Se isto for feito sem critério, você pode mais facilmente esgotar os recursos computacionais do Banco de Dados, ao ponto da execução concorrente de Stored Procedures afetar o desempenho das demais requisições da aplicação.

Outras técnicas pra não esgotar o SGDB

Existem alternativas de adiar um upgrade no SGDB, inclusive em alguns casos as alternativas são a solução para você não precisar comprar um computador da “Nasa” …risos… Normalmente estas alternativas envolvem algum tipo de alteração na aplicação que consome o SGDB.

Réplicas de leitura

Alguns SGDBs permitem criar nativamente réplicas da base de dados acessadas apenas para consulta, onde as cópias de leitura são sincronizadas em requisições assíncronas. Existem muitas partes da aplicação que podem fazer uma leitura “suja”. Neste caso, a aplicação pode ler os dados de uma base sincronizada para leitura, e os processos que precisam de leitura limpa são executados apenas na instância principal. Para isso a aplicação precisaria saber qual e o banco “quente” e qual é o espelho, para fazer as coisas nos lugares certos.

Caches

Outra alternativa é a utilização de caches especialistas, implementados na própria aplicação. Utilizando por exemplo uma instância de um “MemCacheDB” em cada servidor, cada aplicação que pode reaproveitar a leitura de um dado com baixo índice de volatilidade (dados pouco atualizados ou atualizados em momentos específicos), poderiam primeiro consultar o cache, e somente se o cache não têm a informação desejada, a aplicação acessa o banco e popula o cache, definindo uma data de validade. Neste caso, o mais legal a fazer é definir um tempo de validade do cache (Expiration Time). E, paralelo a isso, para informações de baixa volatilidade, a rotina que fizer update desta informação pode eliminar ela do cache no momento que um update for realizado, ou melhor ainda, pode ver se ela se encontra no cache, e em caso afirmativo, ela mesma poderia atualizar o cache 😉

Sequenciamento de operações

Operações de inserção ou atualização de dados que não precisam ser refletidas em real-time no SGDB podem ser enfileiradas em pilhas de requisições, e processadas por um processo dedicado. O enfileiramento de requisições não essenciais em tempo real limita o consumo de recursos para uma determinada atividade. Caso a pilha se torne muito grande, ou um determinado processo dependa do esvaziamento total da pilha, podem ser colocados mais processos para ajudar a desempilhar, consumindo mais recursos apenas quando estritamente necessário.

Escalabilidade Vertical

Devido a esta questão de dificuldade de escalabilidade de bancos relacionais horizontalmente, normalmente recorremos a escalabilidade vertical. Escalamos horizontalmente as máquinas de processamento, colocando mais máquinas menores no cluster e balanceando carga e conexões, e quando a máquina de banco começa a “sentar”, coloca-se uma máquina maior só para o SGDB, com vários processadores, discos, memória e placas de rede. Mas tudo tem um limite, e quando ele for atingido, a sua máquina de Banco de Dados pode ficar mais cara que o seu parque de servidores de processamento.

Dificuldade de Implementação

Usar caches e réplicas e pilhas não é uma tarefa simples, fatores como a própria modelagem da base de dados podem interferir negativamente em algumas destas abordagens. Não se pode colocar tudo em cache, senão não vai ter memória que aguente. O cache é aconselhável para blocos de informações repetidas constantemente requisitadas, e de baixa volatilidade. Também não é necessário criar pilhas para tudo que é requisição, apenas aquelas que não são essenciais em tempo real, e que podem ter um delay em sua efetivação.

Stored Procedures no AdvPL

O ERP Microsiga disponibiliza um pacote de Stored Proecures, aplicadas no SGDB em uso por um programa do módulo “Configurador” (SIGACFG). As procedures foram desenvolvidas para funcionalidades específicas dentro de cada módulo, normalmente aquelas que lidam com grandes volumes de dados, e foi possível criar um algoritmo que realize o processamento dentro do SGDB, trafegando menos dados “pra fora” do Banco de Dados. Normalmente um pacote de procedures é “casado” com a versão dos fontes do Repositório, pois uma alteração na aplicação pode envolver uma alteração na procedure. Os ganhos de performance são nítidos em determinados processamentos, justamente por eliminar uma boa parte do tráfego de informações para fora do SGDB durante os processos.

Conclusão

Dado o SGDB como um recurso “caro e precioso”, como mencionado anteriormente, a utilização de recursos adicionais como réplicas e caches, ajuda a dar mais “fôlego” pro SGDB, você consegue aumentar o seu parque de máquinas e volume de dados processados sem ter que investir proporcionalmente na escalabilidade do SGDB. E em tempos de “cloudificação” , SaaS e IaaS, quando mais conseguimos aproveitar o poder computacional que temos em mãos, melhor !

Desejo novamente a todos TERABYTES de Sucesso 😀

Até o próximo post, pessoal 😉

Referências

“Escalabilidade e performance – Técnicas”

Balanceamento de Carga no Protheus

Introdução

No ano passado, ajudei um colega que estava concluindo um mestrado, cuja tese envolvia diretamente a eficiência de mecanismos de balanceamento de carga e suas abordagens. E, uma vez absorvido algum conhecimento a mais a respeito, acho que podemos dar um mergulho no assunto, e aproveitar para conhecer mais de perto o balanceamento de conexões nativo Application Server para conexões do SmartClient.

Balanceamento de Carga

Também conhecido por “Load balancing”, é um mecanismo que distribui requisições de processamento para múltiplos recursos computacionais, como computadores, “Cluster” de computadores, links de rede, unidades de processamento ou unidades de disco. O objetivo é otimizar o uso de recursos, maximizando disponibilidade e resultados, minimizando tempos de resposta, e evitar sobrecarga de uso de recursos. A utilização de múltiplos componentes com balanceamento de carga ao invés de um único componente deve aumentar a escalabilidade e disponibilidade através da redundância. Normalmente este recurso envolve software e/ou hardware específicos.

Entre as técnicas mais comuns, podemos citar o Round-Robin de DNS (onde uma URL de um WebSite retorna um IP diferente para cada consulta ao DNS, direcionando as requisições para outros servidores), algoritmos de distribuição (normalmente round-robin ou escolha randômica) são usados para decidir qual servidor irá processar uma chamada qualquer. Algoritmos mais sofisticados de balanceamento podem usar como fatores determinantes informações como tempo médio de resposta, estado do serviço, tráfego atual, etc.). Existe também um outro recurso, chamado “Proxy reverso” (Reverse Proxy), que além de outras funcionalidades, também pode ser utilizado para distribuição de requisições e balanceamento.

Pontos comuns

Cada mecanismo atua sob princípios diferentes, e visando suprir as necessidades dos ambientes onde estão inseridos, mas de uma forma geral, todos aderem aos mesmos princípios de desempenho, escalabilidade e resiliência. Como eu já disse, dentre os vários mecanismos disponíveis, não existe um “melhor de todos”, existem aqueles que melhor atendem as suas necessidades. Um sistema complexo pode usar vários níveis de balanceamento usando abordagens diferentes, para partes diferentes do sistema.

Balanceamento de carga no Protheus

O Protheus Server (ou Application Server) do AdvPL possui um mecanismo de balanceamento de carga nativo para as conexões recebidas das aplicações Smartclient. Basicamente, você provisiona dois ou mais serviços do Application Server, com as mesmas configurações de ambiente, distribuídos em quaisquer máquinas, e configura um novo serviço, chamado de “Master” ou “Balance”, com as configurações de balanceamento de carga das conexões especificados na seção [servernetwork] do arquivo de configuração do servidor (appserver.ini).

Quando iniciamos uma aplicação AdvPL a partir de um SmartClient, normalmente o programa executado é responsável pela montagem das telas de interface e pela execução do processamento. Por exemplo, a inclusão de um pedido de venda pelo SmartClient, quando você finaliza a operação e confirma a inclusão, a função de inclusão de pedido é executada pelo mesmo processo de interface, mantendo a interface em stand-by até a conclusão do processo e o programa devolver uma mensagem para a interface e permitir você prosseguir com a operação.

Neste modelo, como o processamento é realizado pelo mesmo processo que atende a conexão, toda a “carga” do processo está atrelada a esta conexão, e a aplicação foi projetada sob o conceito de persistência de conexão. Logo, em se tratando das conexões de SmartClient, ao balancear uma conexão, automaticamente estamos balanceando a “carga”.

Requisitos e comportamentos

Ao configurar no “Master” todos os serviços “Slave” de balanceamento, devemos enumerar host/ip e porta de conexão TCP de cada serviço. Este IP e porta deve ser visível na rede usada pelos SmartClients para estabelecer a conexão. Se houver um firewall entre os smartclients e os application servers, os IPs e portas dos “Slave’s” também precisam ser “abertos”, não basta abrir o IP e porta do “Master”.

O serviço de balanceamento não atua como um “Proxy Reverso”, as conexões estabelecidas com ele são indiretamente redirecionadas ao “Slave” escolhido no momento que a conexão foi estabelecida. Se ele atuasse como um proxy reverso, ele se tornaria um gargalo de comunicação, e caso ele fosse derrubado, todas as conexões atualmente em uso de todo o ambiente cairiam junto.

Quando o Balance recebe a conexão de um SmartClient, ele decide na hora qual dos serviços cadastrados para balanceamento é o mais adequado a receber esta conexão, e retorna ao SmartClient uma instrução de reconexão, indicando ao SmartClient qual é o Serviço que ele deve se conectar. Logo, o Smartclient conecta com o “Master”, recebe a informação de qual o serviço adequado para conextar-se, desconecta do “Master”, e conecta-se diretamente com este serviço.

Toda a regra e controle de distribuição é realizado pelo “Master”. Como ele já possui pré-configurados os serviços que podem receber conexões, ele próprio mantém com cada serviço “Slave” uma conexão dedicada de monitoramento, através da qual ele “sabe” quantos processos estão em execução em cada um dos serviços, e se cada serviço está apto a receber novas conexões. Como cada conexão pode exercer uma carga variável durante seu uso, a uma métrica utilizada é direcionar a nova conexão ao serviço que têm o menor número de processos em execução no momento.

Cada serviço do Protheus, após a Build 7.00.090818, possui um processo interno dedicado ao monitoramento de memória de sua instância de serviço. Caso um limite de ocupação de memória da instância seja atingido, este serviço torna-se automaticamente bloqueado para aceitar novas conexões de SmartClient e subir novos Jobs. O Serviço “Master” se mantém atualizado sobre este status, e não direciona novas conexões para este “Slave” enquanto seu status for “bloqueado”. Veja as referências no final do artigo para as documentações da TDN que abordam em maior profundidade estes assuntos.

Não seria melhor usar outra regra ?

Muitas pessoas já me perguntaram isso, e a resposta é “neste modelo de processamento, não têm opção melhor”. Como a carga e consumo de memória e CPU depende da rotina que será chamada, e a rotina está atrelada a conexão, qualquer cenário de distribuição está sujeito a desequilíbrios, afinal as rotinas possuem consumo de memória, CPU e rede variáveis durante a sua execução, e mesmo se fosse possível transferir um processo para outro serviço “a quente”, se este processo também apresenta a mesma variabilidade de consumo de recursos, a transferência da conexão seria freqüente, tornando-se ineficiente e pesada.

Assumindo que cada conexão persistente possui características variáveis de consumo, o mais elegante é dividir pelo número de processos por serviço. Mesmo havendo um eventual desequilíbrio no consumo de recursos, uma vez atingida uma quantidade de memória acima do normal em um serviço, ele automaticamente “bloqueia-se” para não aceitar mais conexões. Quando ao consumo de CPU, quando sabe-se que determinadas rotinas vão demandar muito tempo e consumo de recursos, pode-se montar um ambiente de balanceamento secundário, com outros “Slave’s” dedicados, onde os usuários de rotinas mais pesadas pode alocar outros serviços, e alguns deles podem ser colocados em scheduler para serem executados após o expediente.

Configuração de Balanceamento no AdvPL

Basicamente, você deve criar uma nova seção no appserver.ini para cada “Slave” que você quer relacionar no balanceamento. Recomenda-se criar um identificador cujo nome esteja relacionado de alguma forma com a máquina servidora e o número do serviço utilizado. Vamos dar um exemplo de balanceamento de serviços em apenas uma máquina, subindo o servidor “Master” na porta 6000, e quatro outros serviços “Slave” nas portas seguintes ( 6001 a 6004 )

[servernetwork]
servers=SL_1,SL_2,SL_3,SL_4
[SL_1]
Server=172.16.10.201
Port=6001
Connections=20
[SL_2]
Server=172.16.10.201
Port=6002
Connections=20
[SL_3]
Server=172.16.10.201
Port=6003
Connections=20
[SL_4]
Server=172.16.10.201
Port=6004
Connections=20

A configuração acima deve ser inserida no appserver.ini do serviço eleito como “Master”, que neste exemplo está na mesma máquina que os “Slave’s””, mas na porta 6000. Quando o serviço é iniciado, ele verifica se existe a seção [servernetwork], e verifica quais são os serviços enumerados para balanceamento. O “Master” sobe um processo interno dedicado para monitorar cada um dos “Slave’s”, e saber em tempo real quantos processos estão sendo executados em cada um, e se eles não estão bloqueados para aceitar novas conexões. Caso um processo não consiga encontrar um “Slave”, ele fica tentando estabelecer uma conexão, e enquanto ele não sabe se o “Slave” está no ar ou não, ele não redireciona nenhuma conexão para aquele “Slave”.

Cada serviço que nós chamamos de “Slave”, não precisa de nenhuma chave especial, ele precisa apenas refletir as mesmas configurações de ambiente entre os servidores, e o mesmo ambiente tem que apontar para o mesmo RootPath, para o mesmo License Server, e todos os ambientes devem estar usando a mesma cópia do repositório.

Como os IPs e Portas de cada “Slave” precisam estar visíveis para o SmartClient conseguir conectar, todos os arquivos de configuração dos SmartClients do parque de máquinas deve estar configurado para apontar diretamente para o IP e porta do “Master”. Caso algum Smartclient não esteja configurado desta forma, e estiver apontando para um determinado “Slave”, isto não atrapalha o balanceamento, pois mesmo que a conexão não tenha sido redirecionada pelo “Master”, ele vai decidir o balanceamento baseado no número de processos em execução em cada “Slave”. Porém, ao apontar diretamente para um “Slave”, se ele estiver bloqueado ou indisponível, a conexão não será direcionada para nenhum outro “Slave”.

Limitação de Balanceamento

Para cada “Slave”, devemos especificar um número na chave “connections”. Este número por padrão não é um número “absoluto” de conexões, mas sim um número de “peso” de distribuição. Por exemplo, ao configurarmos o número 20 na chave “connections” de todos os “Slave’s” para balanceamento, o “Master” vai entender que a carga deve ser distribuída igualmente entre todos os “Slave’s”. Quando configuramos um dos “Slave’s” com connections=40 e os outros três com connections=20, haverá um percentual de diferença nesta distribuição.

A fórmula é calculada primeiro somando todos os números de conexão:

40+20+20+20 = 100

Agora, calculamos o percentual de conexões a serem desviadas para cada “Slave” dividindo a quantidade de conexões pelo numero total, e multiplicando por 100:

40/100 * 100 = 40 %
20/100 * 100 = 20 %
20/100 * 100 = 20 %
20/100 * 100 = 20 %

Logo, se neste ambiente forem feitas 10 conexões, 4 vão para o “Slave”1, e 2 para cada um dos outros “Slave’s”.

Porém, como a distribuição é feita igualmente, a primeira vai pro “Slave” 1 , a segunda também, a terceira vai pra um dos demais “Slave’s”, a quarta também, a quinta também. Quando o balanceamento atinge uma posição de equilíbrio, a sexta conexão deve ir para o balance 1, a sétima também, e as três últimas serão distribuídas para os demais “Slave’s”.

Normalmente em um ambiente equilibrado, o número é o mesmo. Você pode determinar um comportamento de exceção e priorizar mais os serviços de uma determinada máquina, caso ela tenha mais memória e CPU por exemplo. Porém, o mais saudável neste caso é criar mais de um serviço “Slave” na mesma máquina, para haver uma melhor utilização de recursos. Mesmo que este número originalmente não seja um fator limitante, você deve preenchê-lo com um valor aceitável do número máximo de conexões que realmente é saudável fazer em apenas um serviço.

Existe uma configuração na seção ServerNetwork, chamada BALANCELIMIT. Uma vez habilitada, ela considera que o somatório de conexões ( no nosso exemplo, 100 ) é um fator limitante. Por default, se o ambiente passar de 100 conexões, o balanceamento vai continuar distribuindo as conexões usando a mesma regra para os “Slave’s”, até que os “Slave’s” que atingirem um pico de memória não aceitarem mais conexões. Quando habilitamos esta configuração, o Balance vai parar de distribuir conexões para todos os “Slave’s”, caso a soma do numero de processos rodando em todos os “Slave’s” mapeados atingir ou superar a soma de conexões estipuladas nas chaves “connections” de cada “Slave” mapeado no balanceamento. Quando isso acontece, o Balance não redireciona mais conexões, retornando erros de indisponibilidade de serviços para o Smartclient que tentou se conectar.

Desequilíbrio de balanceamento

Enquanto todas as pessoas estão entrando no sistema, a distribuição de conexões é sempre uniforme. Porém, quando os usuários começam a sair do sistema, os serviços podem ficar desbalanceados, onde coincidentemente os usuários que saíram estavam conectados em alguns serviços específicos. Em condições normais de uso, este desbalanceamento não interfere no poder de processamento dos usuários que ainda estão conectados — exceto se coincidirem vários processos consumindo em excesso um determinado recurso de uma máquina, pois neste caso os demais usuários conectados em serviços naquela máquina podem ser penalizados pelo consumo destes processos. De qualquer modo, mesmo temporariamente desbalanceada a quantidade de conexões, as novas conexões feitas no ambiente vão ser priorizadas pelo “Master”, para entrar justamente nos “Slave’s” com o menor número de processos.

Configurações adicionais

Por default, um servidor “Master” pode aceitar conexões vindas do SmartClient, caso não haja nenhum outro “Slave” disponível para redirecionar a conexão. Este comportamento nem sempre é desejável em um ambiente, e pode ser desligado usando a configuração “Master” Connection=0 na seção [servernetwork].

E, existe uma configuração de diagnóstico, para colocar o mecanismo de balanceamento do “Master” em modo “Verbose”, onde cada conexão recebida por um SmartClient é registrada no log de console do Application Server (console.log), informando sua origem, e para qual “Slave” ela foi encaminhada. Basta colocar na seção ServerNetwork a chave Verbose=1

Balanceamento com SSL

Quando utilizamos SSL entre os SmartClients e o Application Server, cada serviço do Protheus, inclusive o “Master”, deve ser configurados com uma conexão SSL, em uma porta distinta. No momento de configurar cada seção de “Slave” no appserver.ini do serviço “Master”, devemos especificar a porta TCP original de cada serviço, e a porta SSL usando a configuração “SecurePort=nnnn”, onde nnnn é o número da porta reservada para conexão segura (SSL) com aquele “Slave”.

Internet e Intranet

Quando disponibilizamos um balanceamento de carga para SmartClients em um ambiente de Intranet, e queremos expandí-lo para a internet ou outra rede externa, a utilização do balanceamento de carga nativo do Application Server exige que os IPs e portas usadas pelos serviços estejam públicos, e também seja visíveis pelo mecanismo de balanceamento (Serviço “Master”), e criar dois serviços de balanceamento. Um IP e porta do “Master” de “Intranet” para serem usados pelos SmartClients dentro da sua rede interna, e um IP e Porta do “Master” para configurar os Smartclients vindos “de fora”.

Internamente, você pode fazer cada balance apontar para um grupo distinto de servidores dedicados ao acesso interno ou externo, ou colocar 2 IPs nas máquinas onde estão todos os seus serviços “Slave”, e pelo balanceamento apontar para todos os serviços disponíves na sua rede, lembrando que o “Master” de acesso interno precisa enxergar os IPs locais, e o “Master” para acesso externo tem que ser configurado com os IPs externos, e precisam acessar cada um dos serviços por estes IPs.

Posso usar “localhost” ?

Não, não pode. Se o “Master” retorna o IP e porta configurados para um “Slave”, na seção correspondente ao “Slave” no appserver.ini do “Master”, no ini do smartclient você apontou um IP e porta do “Master”, mas ao estabelecer a conexão, o “Master” verificou que o “localhost:6001” é a melhor opção, e vai devolver isso pro SmartClient … Logo, o smartclient vai tentar conectar com um serviço Protheus na própria máquina onde ele está instalado. Você pode usar uma máquina grande e colocar muitos serviços de Protheus nela, e fazer um balanceamento “local”, MAS os IPs e Portas usados na configuração devem ser visíveis aos SmartClients, ok ? Se você usar localhost, seu balanceamento somente vai funcionar para os SmartClients que você iniciar de dentro da própria máquina onde estão os serviços do Protheus.

Posso usar outro mecanismo de balanceamento ?

Sim, pode. Você pode usar um NLB, um Proxy reverso, qualquer outro mecanismo que não interfira no conteúdo dos pacotes trafegados e que mantenha a persistência da conexão com o “Slave” escolhido, sendo assim totalmente transparente para as aplicações envolvidas. Normalmente a única desvantagem deste mecanismo é saber se o “Slave” escolhido está realmente disponível para executar o programa. Quando um “Slave” está bloqueado, a porta TCP de conexão permanece no ar e aceita novas conexões, mas responde a elas com uma mensagem de erro e desconectam. Com este balanceamento, caso seja usada uma métrica qualquer, onde naquele momento este serviço esteja atendendo aquela métrica, uma nova conexão vai ser direcionada para ele, mas ele vai atender e retornar erro. Sem persistir a conexão, e a métrica sendo favorável para este “Slave”, você corre o risco de ninguém mais conseguir entrar no sistema, pois todas as conexões serão direcionadas para um “Slave” que está no ar, mas não está preparado para atender as conexões.

O mundo ideal

Um modelo muito interessante de distribuição de processos seria um MVC com o processamento realizado por um pool de processos dedicados, separados do processo de interface. Deste modo, o processo de interface teria um consumo de CPU e memória relativamente mais baixos, em razão apenas da quantidade de componentes de interface, e as requisições efetivas de processamento e iteração com os dados fossem distribuídos em um pool de serviços distribuídos (SOA – Service Oriented Architecture). Se as implementações de processamento mantivessem uma constante média de consumo de recursos por requisição, o mecanismo de balanceamento das requisições de processamento poderia utilizar métricas mais eficientes, direcionando um processo que consome mais CPU para um serviço onde a CPU não esteja “no gargalo”, e sabendo que um processo vai consumir mais memória que os demais, o mecanismo poderia verificar antes de distribuir quais os serviços disponíveis estão com a melhor ocupação, ou que agüentariam aceitar a requisição sem comprometer os processos em execução.

Isto atenderia tanto o cenário de desempenho quando o de resiliência. Com mais de uma máquina física configurada para atender mais de um tipo de requisição, caso um serviço ou uma máquina inteira apresentasse um problema ou indisponibilidade, até mesmo uma manutenção programada, cada máquina teria o seu próprio “orquestrador” de eventos, que ao perceber que uma máquina saiu fora do circuito, poderia direcionar para outro serviço em outra máquina. A sua alta disponibilidade teria um grau de tolerância de acordo com quantos porcento de máquina você tem de “sobra”.

Ao invés de configurar um cluster ativo-passivo, você determina qual é o mínimo de máquinas que precisam estar no ar para dar conta do recado, e coloca uma ou mais máquinas adicionais. Se o seu dia a dia ocuparia duas máquinas inteiras, com serviços espalhados nelas, você pode colocar uma terceira máquina. Logo, você vai ter uma máquina “de sobra”, mas utilizaria as três juntas para não bater 100% do seu uso de recursos. No pior cenário, se uma máquina inteira sair do ar por uma pane física ou uma janela de manutenção de hardware, o software redirecionaria toda a carga para os dois hardwares disponíveis, que em tese deveriam dar conta do recado enquanto essa terceira máquina não “volta”.

Se você coloca o dobro de poder computacional do que você realmente precisa, você pode “perder” momentaneamente até duas máquinas inteiras, que as duas restantes vão bater “na tampa” mas vão segurar o sistema no ar. Se neste cenário a terceira máquina ir pro vinagre, você tem ainda têm duas saídas de emergência: Priorizar os serviços importantes e tirar tudo que é supérfluo do ar naquele momento, limitando a quantidade de conexões e processos, pra a primeira máquina não sobrecarregar — é melhor alguns trabalhando do que tudo parado — e dependendo da granularidade dos serviços, você pode pegar algum equipamento de “emergência” e colocar alguns “Slave’s” neles para operação de “contingência”, distribuindo os serviços essenciais que a maquina 1 não iria dar conta.

Uma outra vertente no balanceamento de carga seria a utilização dos “Slave’s” encadeados, onde os processos seriam distribuídos somente para um slave, até que um “threshold” (limite) seja atingido, então os novos processos passam a alocar o segundo slave, e assim por diante. Este cenário é muito atrativo quando pensamos em escalabilidade para ambientes “Cloud”, onde seria possível por exemplo determinar um número inicial ou mínimo de máquinas virtuais, e criar uma regra para, quando o último slave começar a receber conexões, uma nova VM é disponibilizada no ambiente, e automaticamente inserida no balanceamento. Estas máquinas virtuais adicionais, assim que todas as conexões forem encerradas, poderia ‘fechar-se’ e ser descartada.

Em um ambiente de processamento de múltiplos hardwares, isto também seria útil para fazer uma janela de manutenção de um Hardware. Por exemplo, alterando a priorização dos slaves, deixamos por último na fila todos os slaves de um determinado equipamento, e bloqueamos as conexões para estes slaves. Conforme os usuários fossem desconectando da interface, no final do dia a máquina não estaria com nenhuma conexão, podendo ser tirada inteira do ar para manutenção, se que nenhum usuário desse falta disso.

Conclusão

A busca por eficiência de processos deve ser sempre uma constante em qualquer sistema com previsão de crescimento. E já existem muitos recursos voltados para isso. Entender como a aplicação funciona é um passo a mais para abrir caminhos para mudanças, com mais certeza de sucesso nas mudanças planejadas. Espero que este post tenha dado alguma luz “a mais” neste mecanismo, e que vocês tenham TERABYTES de sucesso na utilização deles 😀

Até o próximo post, pessoal 😉

Documentação TDN

Balanceamento de carga entre serviços

Seção [ServerNetwork]

Processo de monitoramento e controle de memória

Configuração BALANCELIMIT

Referências

Load balancing (computing). (2015, October 16). In Wikipedia, The Free Encyclopedia. Retrieved 00:49, October 31, 2015, from https://en.wikipedia.org/w/index.php?title=Load_balancing_(computing)&oldid=686092130

Reverse proxy. (2015, October 23). In Wikipedia, The Free Encyclopedia. Retrieved 13:47, October 31, 2015, from https://en.wikipedia.org/w/index.php?title=Reverse_proxy&oldid=687157637