Algoritmos – Parte 02 – Permutações

Introdução

No post anterior (Algoritmos – Parte 01 – Loterias), vimos a criação de um algoritmo para realizar combinações simples, que pode ser usado na maioria das loterias numéricas. Agora, vamos ver um algoritmo de permutação — Algoritmo de Heap — e ver como fazer a portabilidade de um pseudo-código para AdvPL.

Algoritmo de Heap

O Algoritmo de Heap é até hoje a forma mais optimizada de gerar todas as possibilidades de permutações em um conjunto de elementos. A permutação é um processo pelo qual podemos criar sequências não repedidas dos elementos de um conjunto, alterando a sua ordem. Por exemplo, partindo de um conjunto de três números (1, 2 e 3), podemos criar as seguintes permutações:

1 2 3 
1 3 2 
2 1 3
2 3 1 
3 1 2
3 2 1

Calcula-se o número total de possibilidades de permutação de um conjunto com a fórmula P(m) = m! –> Onde P é o número de possibilidades de permutação e m é o número de elementos do conjunto, e  “!” é o símbolo da operação fatorial. Por exemplo, em um conjunto de 3 elementos, temos 3! ( 3! = 3*2 = 6) conjuntos ordenados resultantes.

Pseudocódigo

A partir da Wikipedia, por exemplo, podemos obter o pseudocódigo do algoritmo — uma representação em linguagem quase natural da sequência de operações nos conjuntos de dados para se chegar ao resultado. Esta versão do pseudo-código é a não-recursiva.

procedure generate(n : integer, A : array of any):
    c : array of int

    for i := 0; i < n; i += 1 do
        c[i] := 0
    end for

    output(A)

    i := 0;
    while i < n do
        if  c[i] < i then
            if i is even then
                swap(A[0], A[i])
            else
                swap(A[c[i]], A[i])
            end if
            output(A)
            c[i] += 1
            i := 0
        else
            c[i] := 0
            i += 1
        end if
    end while

Agora, vamos converter isso para AdvPL, primeiro de uma forma bem simples, depois de uma forma mais elaborada. Inicialmente, vamos fazer uma tradução “crua” para o AdvPL, porém funcional.

STATIC Function Generate( n , A )
Local c := {} , i
For i := 1 to n
  aadd(c,0)
Next
output(A)
i := 0 
While i < n
  If  c[i+1] < i
    if ( i % 2 ) == 0 
      swap(A, 1 , i+1)
    else
      swap(A, c[i+1]+1, i+1)
    end if
    output(A)
    c[i+1]++
    i := 0
  Else
    c[i+1] := 0
    i++
  EndIf
Enddo


STATIC Function swap(aData,nPos1,nPos2)
Local nTemp := aData[nPos1]
aData[nPos1] := aData[nPos2] 
aData[nPos2] := nTemp
Return

STATIC Function output(A)
Local i, R := ''
For i := 1 to len(A)
  If !empty(R)
    R += ', '
  Endif
  R += cValToChaR(A[i])
Next
conout(R)
Return

Diferenças na Implementação

A primeira diferença nós vemos logo de início ao usar os Arrays em AdvPL. O pseudo-código parte da premissa que um Array de N posições é endereçado de 0 a N-1 — Isto é, o primeiro elemento do Array é o elemento 0 (zero.) Já em AdvPL, o primeiro elemento do array é 1 (um). Logo, nós mantemos toda a lógica do programa inicial, inclusive as variáveis como se o array fosse base 0 (zero), porém na hora de endereçar os elementos do array, somamos uma unidade. Logo:

if c[i] < i

foi transformado para

if c[i+1] < i

A função swap() tem como objetivo trocar os elementos do array, um pelo outro. Como em AdvPL os arrays são passados por referência, podemos implementar a função de troca guardando o valor do elemento informado em uma variável local, depois atribuímos o conteúdo do segundo elemento informado sobre o primeiro, e então atribuímos o conteúdo salvo do primeiro elemento no segundo — vide função swap(). O diferencial dela em relação ao pseudocódigo é que eu passo para ela em AdvPL três parâmetros: O Array, e as duas posições a serem trocadas.

No pseudo-código, para verificar se um determinado numero é par (even em inglês), pode ser em AdvPL verificando  se o resto da divisão por dois é zero. Para isso, poderíamos usar a função mod(), ou de forma mais prática, o operador “%”.

if ( i % 2 ) == 0

Outro ponto de atenção é justamente a chamada da função swap() quando o número não for par. Veja no pseudocódigo:

swap(A[c[i]], A[i])

Agora, em AdvPL, a implementação ficou assim:

swap(A, c[i+1]+1, i+1)

Reparem que o array c[] guarda uma posição de um array. Como estamos trabalhando com array em base 1, eu devo somar 1 para recuperar o elemento do array c, e como o seu resultado será usado para indicar uma posição do array A[], eu também preciso somar 1.

Já a função output(), cujo entendimento óbvio é mostrar um dos conjuntos obtidos na permutação, implementamos simplesmente recebendo o Array , e criando uma string com o conteúdo de seus elementos separados por vírgula. Para testar o fonte acima, vamos usar a seguinte função:

User Function Permuta()
Generate( 4 , {'A' ,'B' ,'C' ,'D' } )
Return

Após salvarem, compilarem e executarem o programa acima, o resultado no log de console do Protheus Server deve ser:

A, B, C, D
B, A, C, D
C, A, B, D
A, C, B, D
B, C, A, D
C, B, A, D
D, B, A, C
B, D, A, C
A, D, B, C
D, A, B, C
B, A, D, C
A, B, D, C
A, C, D, B
C, A, D, B
D, A, C, B
A, D, C, B
C, D, A, B
D, C, A, B
D, C, B, A
C, D, B, A
B, D, C, A
D, B, C, A
C, B, D, A
B, C, D, A

Conclusão

Vendo o fonte assim, prontinho, parece fácil. Porém, eu comecei resolvendo trocar o nome das variáveis da implementação em AdvPL ao transcrever o pseudocódigo, e o programa não gerava os números corretamente. Joguei fora a primeira versão e parti do código original, mantendo o nome das variáveis, então funcionou.

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

Referências

Algoritmos – Parte 01 – Loterias

Introdução

Nos primeiros posts no Blog sobre programação — vide Desmistificando a análise de sistemas e Desmistificando a programação — foi colocada de forma simples a ideia de programar alguma coisa, como sendo apenas uma sequência de instruções e decisões para se realizar uma tarefa. E, realmente é simples assim, o que precisamos fazer é usar corretamente a gramática da linguagem para realizar as tarefas em pequenas etapas, criando tarefas maiores e reaproveitando tarefas menores.

Algoritmos

Uma sequência de instruções para realizar uma tarefa específica pode ser chamada de “Algoritmo” — verifique a definição na Wikipedia – Algoritmo. Não é algo aplicado apenas a programação de computadores.

Consultando também a Wikipédia – Lista de Algoritmos, podemos ver vários exemplos de problemas específicos resolvidos com sequências de operação. Como o algoritmo em si trata apenas da sequência de operações para obter um resultado, ele pode ser implementado em várias linguagens de programação.

Alguns dos mais conhecidos e utilizados são algoritmos de ordenação e busca de dados, compressão, sorteio de números (números randômicos), criptográficos (segurança da informação), entre outros. Na internet podemos obter não apenas vários modelos de algoritmos, mas também várias implementações dos mesmos em várias linguagens de programação.

Algoritmos em AdvPL

Vamos pegar um problema qualquer, e criar um algoritmo para resolvê-lo, um por tópico. Um problema bem interessante de ser resolvido usando um programa de computador, é por exemplo lidar com análise combinatória. Podemos usar um algoritmo de geração de combinações (ou Combinação Simples) para, por exemplo, criar massas de dados explorando as combinações de vários parâmetros para testes de funções, combinar números para cartões de loteria — adoro esse algoritmo — entre outras finalidades. Vamos ver como realizar uma análise combinatória eficiente para loterias em AdvPL.

Combinação para Loterias

Em um determinado cartão de loteria, podemos preencher no mínimo N números diferentes, dentre um total de X números disponíveis, para concorrer a um prêmio caso uma quantidade mínima de números sorteados estejam presentes entre os N números do cartão.

Quando queremos por exemplo, determinar todas as possibilidades de combinação de Y números em cartões de X números, utilizamos um recurso de análise combinatória chamado “Combinação Simples”. Atualmente, para alguns tipos de loterias,o próprio bilhete de apostas já permitem você fazer uma aposta com mais números do que a aposta mínima. Neste caso, a sua aposta equivale a todas as possibilidades de combinação simples dos Y números em um cartão de X números.

MEGA-SENA

Vamos tomar como exemplo a MEGA-SENA, onde a aposta mínima é um bilhete com seis números preenchidos, a um valor de R$ 3,50 . Podemos preencher de 6 a 15 números em um cartão ou bilhete, porém o preço da aposta sobe proporcionalmente ao número de combinações simples da quantidade de números preenchidos em blocos de seis números. Veja a tabela abaixo, de apostas e preços, e a quantidade de apostas de 6 números equivalente.

NUMEROS NO  | VALOR DA     | EQUIVALE A <n> 
BILHETE     | APOSTA       | APOSTAS de 6 números
------------+--------------+------------------------          
6 números   | R$ 3,50      |    1 aposta 
7 números   | R$ 24,50     |    7 apostas 
8 números   | R$ 98,00     |   28 apostas 
9 números   | R$ 294,00    |   84 apostas 
10 números  | R$ 735,00    |  210 apostas 
11 números  | R$ 1617,00   |  462 apostas 
12 números  | R$ 3234,00   |  924 apostas 
13 números  | R$ 6006,00   | 1716 apostas 
14 números  | R$ 10510,50  | 3003 apostas 
15 números  | R$ 17517,50  | 5005 apostas

Quantidade de combinações possíveis

A fórmula para determinar uma combinação simples C, de n números em conjuntos de k elementos é:

C(n,k) = n! / k! * (n-k)!

Onde “!” significa o valor FATORIAL do elemento, “*” é o operador de multiplicação, “/” é o operador de divisão.

Na matemática, o fatorial (AO 1945: factorial) de um número natural n, representado por n!, é o produto de todos os inteiros positivos menores ou iguais a n. A notação n! foi introduzida por Christian Kramp em 1808.

Vamos pegar por exemplo uma combinação de 12 elementos em conjuntos de 6 elementos — equivalente ao cartão de 12 números da MEGA-SENA.

C(12,6) = 12! / 6! * (12-6)!
C(12,6) = 12! / 6! * 6!
C(12,6) = 479001600 / 720 * 720 
C(12,6) = 479001600 / 518400 
C(12,6) = 924

Logo, antes mesmo de começar a fazer a combinação, podemos determinar o número de resultados possíveis.

Combinando elementos

A lógica para realizar a combinação dos elementos é basicamente a mesma, não importa a quantidade de elementos a combinar ou o tamanho do conjunto resultante. Porém, dependendo da forma que ela for implementada, ela tende a ficar mais pesada quanto maior a quantidade de elementos a combinar. Vamos pegar por exemplo a combinação de 7 elementos numéricos, de 1 a 7, em conjuntos de 6 elementos.

C(7,6) = 7! / 6! * (7-6)!
C(7,6) = 7! / 6! * (1)!
C(7,6) = 5040 / 720 * 1
C(7,6) = 7

Logo de antemão, sabemos que esta combinação resultará em 7 resultados. Agora, vamos fazer a combinação. Partindo dos números já ordenados, a primeira combinação é:

1 2 3 4 5 6

Para determinar o próximo resultado, pegamos o próximo numero do conjunto da última posição e incrementamos uma unidade:

1 2 3 4 5 7

Ao tentar fazer a mesma operação para pegar o terceiro resultado, o número 7 é o último elemento do conjunto. Então, precisamos incrementar o número na posição anterior — quinta posição. Quando fazemos isso, o número da posição que atingiu o limite deve ser o próximo elemento relativo a posição anterior. Logo, o próximo resultado será:

1 2 3 4 6 7

Repetindo novamente esta operação, começando da direita para a esquerda, na sexta posição, o elemento 7 já é o último do conjunto. Então vamos para a quinta posição. O próximo elemento desta posição seria o 7, porém ele é o último elemento da combinação, e eu não estou na última posição do conjunto de resultado, isto significa que ele já está em uso em uma posição posterior. Logo, vamos a posição anterior — quarta posição — e incrementamos o número 4 para 5, obtendo o resultado:

1 2 3 5 6 7

Repetindo os passos anteriores, os números 5, 6 e 7 não podem ser incrementados pois já são os elementos finais da combinação. Logo, incrementamos na terceira posição o 3 para 4, depois na segunda posição de 2 para 3, depois na primeira, de 1 para 2, e depois acabou, pois não há como incrementar mais nada.

1 2 4 5 6 7 
1 3 4 5 6 7 
2 3 4 5 6 7

Com isso obtivemos os 7 resultados possíveis. Agora, vamos colocar essa regra em um código fonte. Para facilitar o uso deste recurso, vamos criá-lo como uma Classe em AdvPL. Após construir e otimizar o algoritmo, a primeira versão ficou assim (fonte ACOMB.PRW):

#include "protheus.ch"

CLASS ACOMB FROM LONGNAMECLASS

  DATA nCols
  DATA aElements
  DATA nSize
  DATA aControl
  DATA aMaxVal
  DATA nPos

  METHOD NEW()
  METHOD GETCOMB()
  METHOD NEXTCOMB()
  METHOD GETTOTAL() 

ENDCLASS

METHOD NEW( nCols , aElements ) CLASS ACOMB
Local nI , nMax
::nCols := nCols
::aElements := aElements
::nSize := len(aElements)
::nPos := nCols
::aControl := {}
::aMaxVal := {}
nMax := ::nSize - ::nCols + 1
For nI := 1 to ::nCols
  aadd(::aControl,nI)
  aadd(::aMaxVal, nMax )
  nMax++
Next
Return self

METHOD GETCOMB() CLASS ACOMB
Local nI , aRet := array(::nCols)
For nI := 1 to ::nCols
  aRet[nI] := ::aElements[ ::aControl[nI] ] 
Next 
Return aRet

METHOD NEXTCOMB() CLASS ACOMB
If ::aControl[::nPos] + 1 > ::aMaxVal[::nPos]
  ::nPos := ::nPos - 1 
  If ::nPos < 1 
    Return .F. 
  Endif
  If ::NEXTCOMB()
    ::nPos := ::nPos + 1
    ::aControl[::nPos] := ::aControl[::nPos-1]+1
  Else
    Return .F. 
  Endif
Else
  ::aControl[::nPos]++
Endif
Return .T.

METHOD GETTOTAL() CLASS ACOMB
Local nFat1 := Fatorial( ::nSize )
Local nFat2 := fatorial( ::nCols )
Local nFat3 := Fatorial( ::nSize - ::nCols )
Local nTot := nFat1 / ( nFat2 * nFat3 ) 
Return nTot

STATIC Function Fatorial(nNum)
Local nI := nNum - 1
While nI > 1 
  nNum *= nI 
  nI--
Enddo
Return nNum

A propriedade aControl controla os contadores de cada posição do conjunto de retorno, e a propriedade aMaxVal eu já determino qual é o valor máximo de um determinado contador para a coluna ou posição atual. O Método GetComb() retorna um array com os elementos combinados, e o método NextComb() determina qual a próxima combinação da sequência, atualizando o array aControl. Quando o método NextComb() retornar .F., não há mais combinações possíveis. E, usando o método GetTotal(), eu determino quantas combinações são possíveis.

Para testar a classe acima, vamos usar o seguinte fonte:

User Function ACombTst()
Local nI
Local nResult := 1
Local lEcho := .F. 
Local nTimer
Local nCols , aData := {}

// Monta 35 dezenas para combinar 
For nI := 1 to 35
  aadd(aData,strzero(nI,2))
Next
// Combinação em conjuntos de 6 dezenas 
nCols := 6
oLoto := ACOMB():New( nCols , aData )
nTotal := oLoto:GetTotal()
conout("Elementos ...... "+cValToChar(len(aData)) )
conout("Conjunto ....... "+cValToChar(nCols) )
conout("Combinacoes .... "+cValToChar(nTotal) )
If lEcho
  aRet := oLoto:GETCOMB()
  conout("("+strzero(nResult,6)+") "+aRet[1]+" "+aRet[2]+;
         " "+aRet[3] +" "+aRet[4]+" "+aRet[5]+" "+aRet[6] )
endif
nTimer := seconds()
While oLoto:NEXTCOMB()
  nResult++
  If lEcho
    aRet := oLoto:GETCOMB()
    conout("("+strzero(nResult,6)+") "+aRet[1]+" "+aRet[2]+;
           " "+aRet[3] +" "+aRet[4]+" "+aRet[5]+" "+aRet[6] )
  Endif
Enddo
nTimer := seconds() - nTimer
conout("Resultados ..... "+cValToChar(nResult) )
conout("Tempo .......... "+str(nTimer,12,3)+" s.")
Return

No meu notebook, determinar as 1623160 combinações possíveis de 35 números em blocos de 6 demorou aproximadamente 3,5 segundos. Vejamos o log de console:

Elementos ...... 35
Conjunto ....... 6
Combinacoes .... 1623160
Resultados ..... 1623160
Tempo .......... 3.531 s.

Sim, neste teste eu não peguei os resultados, e não imprimi os resultados, eu apenas chamei em loop o método NextComb() até ele calcular todas as combinações. Para a aplicação imprimir os resultados em console, basta colocar .T. na variável lEcho e recompilar o fonte. Não é necessário dizer que, haverá uma boa diferença de desempenho quando você passar a resgatar cada uma das combinações e mostrar/gravar cada uma no log de console.

Dividindo o número total de combinações pelo tempo que a aplicação demorou, a rotina gerou aproximadamente 459688 resultados por segundo. Eu diria que isso é um tempo fantasticamente rápido. O tempo necessário para gerar estas combinações é simplesmente irrelevante perto do tempo que você deve gastar para, por exemplo, registrar estas combinações em uma tabela ou Banco de Dados.

Outras Loterias

Vamos pegar agora a Lotofácil. Cada cartão tem 25 dezenas, a aposta mínima é um bilhete com 15 dezenas preenchidas, e o prêmio máximo é você acertar os 15 números. As chances de você ganhar jogando um bilhete com a aposta mínima é 1 em 3.268.760. Afinal, se você fizer todas as combinações de 25 dezenas em grupos de 15, é exatamente esse o número de combinações possível. Vamos ver isso rodando o programa ? Basta alterar o programa de teste para criar 25 dezenas, e atribuir 15 para nCols.

Elementos ...... 25
Conjunto ....... 15
Combinacoes .... 3268760
Resultados ..... 3268760
Tempo .......... 12.002 s.

Pode fazer a mesma coisa para a MEGA-SENA, são 60 elementos em conjunto de 6. Garanto que vai demorar bem mais que 12 segundos, afinal a MEGA-SENA são 50.063.860 possibilidades ou combinações, mais de 15 vezes do que a Loto Fácil. Vamos rodar, apenas pra ver quanto tempo demora.

Elementos ...... 60
Conjunto ....... 6
Combinacoes .... 50063860
Resultados ..... 50063860
Tempo .......... 94.201 s.

Conclusão

Mesmo sabendo de tudo isso, até agora eu não ganhei na loteria, pois matematicamente as chances de um número qualquer ser sorteado é uma para o conjunto de números. As chances de acertar todos os números de uma loteria com uma aposta é uma para o conjunto de possibilidades / combinações dos números que eu posso colocar no cartão. Matematicamente, as chances de qualquer aposta — qualquer uma, inclusive por exemplo as dezenas “01 02 03 04 05 06” , ou “05 10 15 20 25 30”, são as mesmas. A única forma de aumentar matematicamente a sua probabilidade ou chance de acerto é jogar mais cartões diferentes. E, no final das contas, você pode jogar um cartão com 16 números e não acertar nenhum.

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

Referências

 

 

Identificando Problemas – Queries lentas – Parte 04

Introdução

Continuando o assunto de identificação de problemas, vamos ver agora o que e como lidar com queries que não apresentam um bom desempenho. Antes de chamar um DBA, existem alguns procedimentos investigativos e algumas ações que ajudam a resolver uma boa parte destas ocorrências.

Queries, de onde vêm?

Quando utilizamos um Banco de Dados relacional homologado com o ERP Microsiga / Protheus, todas as requisições de acesso a dados passam pela aplicação DBAccess. Como eu havia mencionado em um post anterior, o DBAccess serve de “gateway” de acesso ao Banco de Dados, e serve para emular as instruções ISAM do AdvPL em um banco relacional.

Logo, as queries submetidas pelo DBAccess ao Banco de Dados em uso podem ser de de dois tipos:

  • Queries emitidas (geradas) pelo DBAccess para atender a uma requisição ISAM — DBGoTop(), DBGoBottom(), DbSkip(), DBSeek().
  • Queries abertas pela aplicação AdvPL para recuperar dados diretamente do Banco de Dados.

Quando utilizamos o DBAccess Monitor, para realizar um Trace de uma conexão do Protheus com o DBAccess, podemos visualizar as operações solicitadas do Protheus ao DBAccess, e as respectivas queries submetidas ao Banco de Dados.

Como identificar uma query “lenta”?

Normalmente encontramos uma ou mais queries com baixo desempenho quando estamos procurando a causa da demora de um determinado processo. A descoberta acaba sendo realizada durante a análise de um Log Profiler obtido durante a execução da rotina, ou também através de um trace da conexão do DBAccess usando o DBMonitor.

Agora, podemos também usar o próprio DBAccess para gerar um log das queries que demoraram mais para serem abertas pelo Banco de Dados. Basta utilizar a configuração MaxOperationTimer, onde podemos especificar um número de segundos limite, a partir do qual um log de advertência deve ser gerado pelo DBAccess, caso o retorno da abertura de uma Query ultrapasse o tempo definido.

O que é uma query lenta?

Definir lentidão normalmente não está ligado apenas a medida absoluta de tempo, mas sim é relativa a urgência ou necessidade de obter a informação rapidamente, versus a quantidade de informações a serem avaliadas e retornadas.

Por exemplo, quando a aplicação AdvPL executa uma instrução como DBGoto(N), onde N é o número de um registro da base de dados, o DBAccess vai montar e submeter uma Query contra o banco de dados, para selecionar todas as colunas da tabela em questão, onde o campo R_E_C_N_O_ é igual ao número N informado.

SELECT CPO1,CPO2,CPO3,...N FROM TABELA WHERE R_E_C_N_O_ = N

Meu chapa, essa Query deve rodar normalmente em menos de 1 milissegundo no banco de dados, por duas razões: A primeira é que a coluna R_E_C_N_O_ é a chave primária (Primary Key) de todas as tabelas criadas pelo DBAccess, então naturalmente existe um índice no Banco de Dados usando internamente para achar em qual posição do Banco estão gravadas as colunas correspondentes a esta linha. E a segunda é que, apenas uma linha será retornada.

Se não existisse um índice para a coluna R_E_C_N_O_, o Banco de Dados teria que sair lento a tabela inteira, sequencialmente, até encontrar a linha da tabela que atendesse esta condição de busca. Esta busca na tabela de dados inteira sem uso de índice é conhecida pelo termo “FULL TABLE SCAN”.

Agora, imagine um SELECT com UNION de mais quatro selects, finalizado com um UNION ALL, onde cada Query faz SUB-SELECTS e JOINS no Banco de Dados, e o resultado disso não vai ser pequeno … Mesmo em condições ideais de configuração do Banco de Dados, não é plausível exigir que uma operação deste tamanho seja apenas alguns segundos.

Causas mais comuns de degradação de desempenho em Queries

Entre as mais comuns, podemos mencionar:

  1. Ausência de um ou mais índices — simples ou compostos — no Banco de Dados, que favoreçam um plano de execução otimizado do Banco de Dados para recuperar as informações desejadas.
  2. Estatísticas do Banco de Dados desatualizadas.
  3. Picos de CPU , Disco ou Rede, na máquina onde está o DBAccess e/ou na máquina onde está o Banco de Dados.
  4. Problemas de hardware na máquina do Banco de Dados ou em algum dos componentes da infra-estrutura.
  5. Problemas de configuração ou de comportamento do próprio Banco de Dados sob determinadas condições.
  6. Excesso de operações ou etapas do plano de execução, relacionadas a complexidade da Query ou da forma que a Query foi escrita para chegar ao resultado esperado.

Recomendo adicionalmente uma pesquisa sobre “Full Table Scan” e outras causas possíveis de baixo desempenho em Queries. Quanto mais infirmação, melhor. E, a propósito, mesmo que a tabela não tenha um índice adequado para a busca, se ela for  realmente pequena (poucas linhas) , o Banco de Dados internamente acaba fazendo CACHE da tabela inteira em, memória, então um Full Table Scan acaba sendo muito rápido, quando a tabela é pequena. Esta é mais uma razão por que muitas ocorrências de desempenho relacionados a este evento somente são descobertas após a massa de dados crescer representativamente no ambiente.

Troubleshooting e boas práticas

Normalmente as melhores ferramentas que podem dar pistas sobre as causas do baixo desempenho de uma Query são ferramentas nativas ou ligadas diretamente ao Banco de Dados, onde a ferramenta é capaz de desenhar e retornar — algumas inclusive em modo gráfico — o PLANO DE EXECUÇÃO da Query. Neste plano normalmente as ferramentas de diagnóstico informam quando está havendo FULL TABLE SCAN, e quais são as partes da Query que consomem mais recursos no  plano de execução. Algumas destas ferramentas inclusive são capazes de sugerir a criação de um ou mais índices para optimizar a busca dos dados desejados.

Mesmo sem ter uma ferramenta destas nas mãos, normalmente necessária para analisar queries grandes e mais complexas, podemos avaliar alguns pontos em queries menores “apenas olhando”, por exemplo:

  1. Ao estabelecer os critérios de busca — condições e comparações usadas na cláusula WHERE — procure usar diretamente os nomes dos campos, comparando com um conteúdo fixo,  evitando o uso de funções. É clado, vão existir exceções, mas via de regra procure estar atento neste ponto.
  2. Evite concatenações de campos nas expressões condicionais de busca. Imagine que você tem uma tabela com dois campos, e você tem nas mãos, para fazer a busca, uma string correspondendo a concatenação destes dois valores. Muto prático você fazer SELECT X FROM TABELA WHERE CPO1 || CPO2 = ‘0000010100’, certo ? Sim, mas mesmo que você tenha um índice com os campos CPO1 e CPO2, o Banco de Dados não vai conseguir usar o índice para ajudar nesta Query — e corre o risco de fazer FULLSCAN. Agora, se ao inves disso, você quebrar a informação para as duas colunas, e escrever SELECT X FROM TABELA WHERE CPO1 = ‘000001’ AND CPO2  = ‘01000’ , o Banco de Dados vai descobrir durante a montagem do plano de execução que ele pode usar um índice para estas buscas, e vai selecionar as linhas que atendem esta condição rapidinho.
  3. O Banco de Dados vai analisar a sua Query, e tentar criar um plano de acesso (ou plano de execução) para recuperar as informações desejadas o mais rápido possível. Se todas as condições usadas na cláusula WHERE forem atendidas por um mesmo índice, você ajuda o Banco de Dados a tomar a decisão mais rapidamente de qual índice internamente usar, se você fizer as comparações com os campos na ordem de declaração do índice. Por exemplo, para o índice CPO1, CPO2, CPO3, eu sugiro  uma Query com SELECT XYZ from TABELA WHERE CPO1 = ‘X’ AND CPO2 = ‘Y’ AND CPOC3 >=  ‘Z’

Queries emitidas pelo DBAccess

As queries emitidas pelo DBAccess no Banco de Dados para emular o comportamento de navegação ISAM são por natureza optimizadas. Para emular a navegação de dados em uma determinada ordem de índice, o DBAccess emite queries para preencher um cache de registros — não de dados — usando os dados dos campos como condições de busca, na mesma sequência da criação do índice. E, para recuperar o conteúdo (colunas) de um registro (linha), ele usa um statement preparado, onde o Banco faz o parser da requisição apenas na primeira chamada, e as demais usam o mesmo plano de execução.

Porém, isto não impede de uma ou mais queries emitidas pelo DBAccess acabem ficando lentas. Normalmente isso acontece quando é realizada uma condição de filtro na tabela em AdvPL, onde nem todos — ou nenhum —  os campos utilizados não possuem um índice que favoreça uma busca indexada, fazendo com que o Banco acabe varrendo a tabela inteira — FULL SCAN — para recuperar os dados desejados.

Este tipo de ocorrência também é solúvel, uma vez determinado qual seria a chave de índice que tornaria a navegação com esta condição de filtro optimizada, é possível de duas formas criar este índice.

Criando um índice auxiliar

A forma recomendada de se criar um índice auxiliar é acrescentá-lo via Configurador no arquivo de índices do ERP (SIX), para que ele seja criado e mantido pelas rotinas padrão do sistema. Porém, para isso este índice não pode conter campos de controle do DBAccess no meio das colunas do índice, e para se adequar ao padrão do ERP, seu primeiro campo deveria sempre ser o campo XX_FILIAL da tabela.

Quando esta alternativa não for possível, existe a possibilidade de criar este índice diretamente no Banco de Dados. Porém, a existência desse índice não deve interferir na identificação de índices de emulação ISAM que o DBAccess faz quando qualquer tabela é aberta. Para isso, a escolha do NOME DO ÍNDICE é fundamental.

Um índice criado diretamente no Banco de Dados para estes casos deve ter um nome que seja alfabeticamente POSTERIOR aos índices declarados no dicionário de dados (SIX). Por exemplo, existe uma tabela ABC990, que possui 8 índices. O ERP Microsiga nomeia os índices da tabela no padrão usando o nome da tabela e mais um número ou letra, em ordem alfabética. Logo, os oito primeiros indices da tabela chamam-se, respectivamente, ABC9901, ABC9902 … ABC9908.

Nomeie seu primeiro índice customizado com o nome de ABC990Z1. Caso seja necessário mais um índice, ABC990Z2, e assim sucessivamente. Chegou no 9, precisa de mais um índice, coloque a letra “A” — ABC990ZA. Dessa forma, estes índices ficarão por último na identificação do DBAccess, e por eles provavelmente não se encaixarem no padrão do ERP, você não vai — e não deve — usá-los dentro de fontes customizados AdvPL — Estes índices vão existir apenas no Banco de Dados, para favorecer a execução de queries especificas ou filtros específicos de navegação.

Precauções

Uma vez que um ou mais índices sejam criados dentro do Banco de Dados, sem usar o dicionário ativo de dados (SIX), qualquer alteração estrutural na tabela feita por uma atualização de versão ou outro programa pode apagar estes índices a qualquer momento, caso seja necessário, e isso vai novamente impactar o desempenho da aplicação. Para evitar isto, é possível escrever uma rotina em AdvPL — customização — para verificar usando Queries no banco de dados se os índices auxiliares criados por fora ainda existem, e até recriá-los se for o caso. Pode ser usado para isso o Ponto de Entrada CHKFILE() , junto das funções TCCanOpen() — para testar a existência do índice — e TcSqlExec() — para criar o índice customizado usando uma instrução SQL diretamente no banco de dados.

Outras causas de degradação de desempenho

Existem ocorrências específicas, que podem estar relacionadas desde a configuração do Banco de Dados, até mesmo problemas no mecanismo de montagem ou cache dos planos de acesso criados pelo Banco para resolver as Queries. Outas ocorrências podem estar relacionadas a execução de rotinas automáticas ou agendadas no próprio servidor de Banco de Dados. Normalmente o efeito destas interferências são percebidos como uma lentidão momentânea porém generalizada, por um período determinado de tempo.

Conclusão

Use a tecnologia ao seu favor, e em caso de pânico, chame um DBA! Muitas vezes existe uma forma mais simples ou com menos etapas para trazer as informações desejadas. Outras vezes é mais elegante, rápido e prático escrever mais de uma query menor, do que uma Super-Query-Megazord.

Desejo a todos um ótimo final de semana, e muitos 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

Dicas valiosas de programação – Parte 01

Introdução

Ao longo do tempo, cada analista de sistemas e programador adquire experiência e proeficiência em algoritmos e soluções de software para atender a necessidade de seus clientes. Normalmente cada linguagem têm os seus “pulos do gato”, muitos são conhecidos e são independentes da linguagem de programação. Neste post vamos abordar os mais conhecidos, e que sempre devem ser lembrados, com ênfase no “porquê” é melhor fazer assim do que assado.

Separe o Processamento da Interface

Basicamente, você escreve um pouco mais de código, visando criar no mínimo duas etapas de processamento em códigos distintos. O primeiro código, responsável pela camada de interface, deve criar para o usuário uma interativa de fornecer os dados e parâmetros para um processamento.

Por exemplo, uma inclusão de novo cliente, ou a emissão de um relatório. Você constrói uma interface onde o usuário possa preencher os campos de cadastro ou os parâmetros a serem considerados na emissão do relatório.

O segundo código é responsável pela realização efetiva do processamento. Porém, este código não deve ter acesso a interface. Ele deve receber os dados para serem processados como argumento da função, e o retorno de sua execução deve indicar se o processamento foi executado com sucesso, e em caso de falha, os detalhes pertinentes da falha — como por exemplo parametrização inválida, tentativa de incluir cliente já existente, etc…

Por que fazer dessa forma ?

  1. Uma rotina escrita para não depender de interface pode ser executada SEM interface, em processamentos do tipo “JOB”. Isso torna possível, por exemplo, a criação de JOBs dedicados para realizar várias inclusões ao mesmo tempo, ou por exemplo a execução do relatório através de um programa do tipo “Agendador de Tarefas” (ou Scheduler) sem a dependência de ser iniciado pelo usuário.
  2. A rotina responsável pelo processamento deve possuir as validações adequadas da parametrização envolvida na sua execução, assim você escreve uma única função de processamento, e pode fazer a chamada dela a partir de várias interfaces ( SmartClient, Telnet, WebService, HTML, REST, etc ), onde a regra de negócio é mantida em apenas um código.
  3. As interfaces de chamada da função de processamento não necessariamente precisam apresentar uma tela ao usuário. Por exemplo, a sua função de processamento recebe parâmetros em array, no formato chave=valor. Com algumas linhas de código é possível escrever uma classe de Web Services Server, responsável por publicar uma funcionalidade e tratar os parâmetros (SOAP ou REST) recebidos, converter em array para a chamada da função, e retornar o status da tarefa.
  4. Mesmo utilizando uma arquitetura Client-Server, como o caso do AdvPL, o contexto de execução de um processo de interface — SmartClient por exemplo — somente é mantido no ar enquanto a aplicação Client está sendo executada. Logo, se durante um processamento de código AdvPL no servidor, sendo executado em um contexto (ou Thread) iniciada a partir de um SmartClient, se o APPlication Server perder o contato com o SmartClient — normalmente por instabilidade na rede ou mesmo um problema na máquina onde o Smartclient está sendo executado ), o processo em execução no servidor é encerrado imediatamente — afinal sua existência é baseada na premissa da interface gráfica com o usuário.
  5. Justamente em processamentos mais longos, como por exemplo processos em lote — ou a emissão de um relatório que demora duas horas … Não vale a pena deixar a interface aberta e travada com uma tela de “aguarde”, e rodar o relatório neste processo. Imagine que falta 10 minutos pro relatório efetivamente ser emitido, e a energia “piscou” no terminal, e o terminal perdeu por um instante a conexão de rede .. lá se foi uma hora e 50 minutos de trabalho jogados fora, em uma infra-estrutura onde o servidor de aplicação normalmente está em um ambiente com rede redundante, alimentação de energia redundante, alta disponibilidade e afins .. o relatório teve que ser emitido novamente, e iniciar “do zero” por que a energia “piscou” na sala onde estava o terminal do sistema.

Então, vamos mudar tudo pra JOB ?

Não, não é assim que funciona. Chamar um processo separado do processo atual para realizar uma tarefa têm custo, exige mais controle(s), tratamento(s) de erro, normalmente exige um algoritmo de gerenciamento de processos, para não deixar o sistema subir mais processos do que ele “aguenta” processar. Um erro de lógica neste mecanismo ou a ausência dele pode levar um sistema a um estado de esgotamento de recursos.

O que colocar em JOB ?

Via de regra, normalmente é recomendável colocar em JOB os seguintes tipos de processamento:

  1. Emissões de relatórios pesados e/ou demorados. Aquele resumo financeiro do dia anterior, que só pode ser executado depois da meia note, que demora pelo menos meia hora para ser emitido, e que você precisa ver no dia seguinte as 8 horas da manhã … coloque em um agendador de tarefas (Scheduler)  para rodar as 5 ou 6 da manhã. Quando você entrar no sistema e abrir o Spool de Impressão,  seu relatório deve estar lá. Se não estiver, o Scheduler deve ter registrado alguma falha.
  2. Tarefas complementares de processamentos, onde não existe a necessidade imediata do resultado para a continuidade do processo. Por exemplo, um envio de e-mail informativo de uma determinada operação. Quando utilizadas em pequenas quantidades, subir um jobs adicional na execução do programa é suficiente. Agora, caso múltiplas rotinas de envio de email sejam chamadas ao mesmo tempo, sob pena de haver um esgotamento de recursos, é prudente criar uma fila de envio de emails — por exemplo uma tabela na aplicação contendo os emails pendentes de envio, e um ou mais JOBS dedicados ao envio de email. Assim, enquanto os processos vão inserindo os emails a serem enviados em uma tabela do banco de dados, um ou mais processos dedicados vão enviando esses emails, um a um, sem sobrecarregar o servidor atual com muitos jobs, e sem sobrecarregar o próprio provedor de email.
  3. Tarefas especializadas concorrentes, onde múltiplos processos concorrem pelo mesmo recurso, que só pode ser usado por um processo por vez. Normalmente estes casos são exemplificados por rotinas de atualização que exigem o bloqueio temporário de um registro. Neste caso, seria mais elegante criar um e apenas um JOB dedicado para fazer esta atualização, e este JOB pegar os dados que devem ser atualizados de uma fila. Assim, outros processos que precisem fazer esta tarefa não precisam esperar o processo dedicado terminar de rodar a requisição.

Conclusão

Escrever a rotina de processamento sem depender de interface, além de naturalmente permitir a execução dessa rotina dentro de um processo de interface, lhe abre a oportunidade de executá-la em um processo separado, mas não o obriga a fazer isso. Se uma rotina já existe, e foi escrita para depender de uma interface, você deve decidir se usar um JOB é a melhor alternativa para as suas necessidades, e avaliar o custo de fazer essa separação. Em linhas gerais, para não sofrer amanhã, comece a separar processamento da interface hoje, nas novas funcionalidades que você implementa, assim a utilização delas em Job, se assim conveniente ou necessário for, será menos traumático.

Desejo a todos(as)  TERABYTES de SUCESSO 😀

 

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

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