Una idea! Procesar grandes volúmenes de datos y no usar “Batch”

Aquí me veo de nuevo, tratando de encontrar la forma de no usar Batch cuando tengo que interactuar en tiempo real con el usuario… pero cómo lo hago??

Necesito procesar una gran cantidad de datos, y cuando digo procesar no solo hacer consultas en base de datos si no que necesito hacer cálculos sobre esos datos-registros y cuando los tenga todos listos mostrárselos al usuario sin que se aburra esperando por ellos!! Yo pierdo la concentración con el vuelo de una mosca … si el usuario se parece a mi , uhm… lo pierdo fijo, pierdo un cliente , noooo! ( leer el no con un tono dramático ).

Pues bien vamos a ver, analiza el problema Carolina, analiza , que no cunda el pánico!!

El problema es:

Tengo que procesar como mínimo 60K records/registros ( sí, como lo lees, necesito procesar mínimo 60000 registros **Nota: trabajando actualmente en una version que maneja mas de 100K registros. Continua atento a los siguientes posts y GitHub links que pondré pronto**   ).

Tras procesarlos tengo que mostrar los resultados en pantalla. (Cuando digo procesarlos , es que tengo que hacer diferentes cálculos con ellos ).

Dichos registros pertenecen a diferentes objetos (luego tengo que consultar diferentes objectTypes, en concreto para mi caso, tengo un límite de 23 objetos/tablas/Schema.objectTypes diferentes).

No me gustaría usar un proceso Batch pero …por qué? 

– No más de 5 procesos Batch por organización: El primer y uno de los más importante  <no me gustaría usar Batch por..>   porque  como máximo en una organización ( llamémosla org) no puedo tener más de 5 batches a la misma vez.  Con lo que me pregunto, si el usuario tiene otros procesos a la misma vez tengo que esperar, entonces la experiencia de usuario disminuye (aparte debería implementar un “poller system” para preguntar cada cierto tiempo si es posible lanzar mi batch (Sobre esto hablaré muy pronto en otro post, cómo implementar tu propio control de procesos Batches para comprobar cuando lanzar el siguiente, si hay errores, cuántos records se han procesado…)

-Tiempo de proceso de esos 60K registros

– Uso de Iterable : Si utilizo un proceso Batch para consultar 23 tipos de objetos diferentes necesitaría usar un Iterable<sObject> , con lo que me limita a la cantidad de 50K (pues va a ser que  no puedo)

– Concatenar Batches  recursivamente : Vale entonces uso Database.QueryLocator  … si continúo con esta opción puedo procesar hasta 50 millones de registros/records de cada Schema.ObjectType. Pero para procesar diferentes objectos tengo que concatenar Batches con lo que cuando termine un proceso Batch en el método “finish” del actual proceso Batch se deberá de llamar al siguiente, cuánto tardaría en terminar esta cadena de Batches? Qué pasaría si hay 5 batches ejecutándose ya? En qué momento todo estaría listo para que el usuario en UI vea los datos?  …

Parece que tengo que buscar otra opción para ello. Voy a pensar que tengo:

–  Tengo que implementar una bonita UI y que el usuario pueda usar fácilmente, luego tengo UI ( puede que la mejor opción sea una página Visualforce más JQuery incluso Sencha más JavaScript Remoting )

–  Tengo conexión con esa página Visualforce con lo que tengo una clase controller y puede que unas cuantas clases “service” que pueda convertir más tarde en API

– Tengo la posibilidad de crear objetos

…. Entonces lo que se me ocurre es lo siguiente : IDEA!!

Voy a usar mi UI, mi parte “cliente” como el maestro de orquesta. Realmente lo que ocurrirá es  que el cliente y servidor harán las veces de AsyncBatchApex table.

La idea es que desde mi página VF usando JavaScript Remoting voy a mandar instrucciones a mi controlador + clases “Service” para realizar dichos cálculos , dicho procesamiento en lotes.

En Back-End voy a ir procesando dichos datos usando diferentes llamadas de JavaScript remoting, lo que hará resetear mis límites en cada llamada .

Entonces en cliente tendré una llamada recursiva a mi función en back-end usando JavaScript Remoting.

En cada llamada mi parte cliente mandará al controlador qué objeto debe de procesar, toda la lógica , cálculos estarán en mi servidor, en mi Back-End. Con lo cual lo que haré será consultar mi base de datos, procesar dichos datos y una vez que tenga los resultados los voy a guardar en un objeto  temporal (o varios , dependiendo qué estructura es más beneficiosa para mi caso). Este objeto temporal será el que mi UI va a usar para mostrar los resultados, o una estructura auxiliar que le mande.

Tengo varias opciones en este punto :

–  Una vez que la primera llamada ha terminado mando los nuevos datos procesados, para que UI los muestre o guarde para más tarde.

–  Tengo otra función en mi UI que consultará dichos datos, nuevos registros,  cuando estén procesados, preguntando a mi objeto temporal.

En cada llamada podré procesar 10000  registros, por qué?  Porque mi número total de registros procesados ​​como resultado de sentencias DML es como máximo 10000 (Governor Limit), ya que en cada llamada inserto en base de datos 10K registros draft.  Luego como máximo tendré 6 llamadas a servidor para procesar mis 60K registros iniciales .

La siguiente pregunta sería, ¿ y qué pasa si tengo que procesar más de 10000 registros de un mismo tipo?  Pues se lo comunicaré a mi parte cliente para que vuelva a llamar con el mismo tipo de objeto.

Y cómo hago para no volver a seleccionar  y procesar los registros que ya han sido procesados?  Pues tengo varias opciones, puedo mandárselos de vuelta solo como una lista de IDs , obviamente teniendo en cuenta que como máximo podemos mandar 15 MB en cada llamada de JavaScript Remoting, pero no me preocupo estoy a salvo, ya que cada caracter es 1 byte. (esto solamente aplicaría  cuando hablamos de IDs, con lo que 15 caracteres en el id son 15 bytes ).

La parte cliente va a ser la encargada de ir añadiendo y guardando estos IDs de los registros procesados para luego mandarlos a la parte servidor y que no se vuelvan a procesar.

¿Cómo sería entonces la comunicación  entre cliente y servidor ?

Parte Cliente : Hola Servidor, necesito procesar registros de tipo Cuenta – Account  (sí, mi parte cliente es muy educada y saluda)

Parte Servidor:  De acuerdo.. procesados y creados 10000 registros de tipo objeto temporal ( DraftObject__c) con la nueva información. Aquí te mando dicha información que necesitas (puede que use JSON o una estructura temporal) pero tengo que decirte que aún quedan registros de tipo Cuenta-Account por procesar.

Parte Cliente: Gracias, muestro los 10000 cálculos relacionados con los registros procesados al usuario por si quiere trabajar con ellos (Es opcional porque puede que dichos cálculos dependan de los siguientes cálculos, es decir le doy la opción a cliente de decidir si los muestra o espera a todos)  Y te vuelvo a llamar para que proceses más registros de tipo cuenta-Account, ya que me has dicho que aún quedan. Además te mando los que ya has procesado para no volver a procesarlos.

Parte Servidor : De acuerdo, procesando los registros restantes, creando los registros necesarios en DraftObject__c y aquí te mando la información que contiene los cálculos que acabo de insertar en draft. Ya no hay más registros de tipo Cuenta-Account, necesitas que procese algún otro objeto?

Parte Cliente:  Pues ahora que lo dices necesito que proceses Oportunidades.

Parte Servidor:  Procesando Oportunidades, cálculos realizados, objetos temporales creados y estos son los resultados. No hay más oportunidades que procesar. Necesitas que procese algún otro tipo de objeto?

Parte Cliente: Perfecto, no necesito nada más, ya he terminado y como ya me has mandado la información sigo con lo mío, si necesito algo en el futuro te llamo. Gracias!

Luego el código podría ser algo como sigue:

VF page + JSRemoting

<apex:page showHeader="false" sidebar="false"
           controller="RetrieveDataController">

        <script src="{!URLFOR($Resource.jqm,'js/jquery-1.9.1.min.js')}"></script>
        <script src="{!URLFOR($Resource.jqm,'js/jquery.mobile-1.3.2.min.js')}"></script>

 <script>
    var $j = window.jQuery.noConflict();
        $j(document).ready(function() {

            init();

            regBtnClickHandlers();

        });

    var recordTypes ;
    var iterator;
    var nextRecordType;
   // var alreadyProcessedINWholeProcess = new Array();
    var alreadyProcessedINWholeProcess;

    function init()
    {
        debugger;
        //we will initialise the recordTypes that I'll need later
        recordTypes = new Array( "Account", "Transaction__c" ); // in the example only 2
        //**********
        //**** TAKE CARE WHICH RECORD TYPER YOU CHOOSE AS LATER ON WE WILL CREATE RECORDS IN DRAFT
        //**** THEN IF YOU CHOOSE DRAFT WILL BE A NEVER END!! INFINITE RECURSIVITY
        //**********

        iterator = recordTypes.length -1 ;
        nextRecordType = recordTypes[iterator];
        alreadyProcessedINWholeProcess = new Array();

    }

    regBtnClickHandlers();
    function regBtnClickHandlers() {
      $j('#retrieveData').click(function(e) {
        clickButton();
      });
    }

    function clickButton()
    {
        retrieveData(nextRecordType);

    }

    function retrieveData(recordType, recordsProcessed)
    {
        debugger;
        var back=false;

        var alreadyProcessed = [];

        if(recordsProcessed != null && recordsProcessed.length != 0)
            alreadyProcessed.push(recordsProcessed);

         Visualforce.remoting.Manager.invokeAction('{!$RemoteAction.RetrieveDataController.loadData}',
            nextRecordType , alreadyProcessed, function(result, event)
        {
            //retrieving the data that is storage in my result
            debugger;

            //Read through the result
            var recordsArray = result.drafts;
            var recordsArray = result.records;
            alreadyProcessed.push(result.recordIdsAlreadyProcessed); // this records will be storage
            alreadyProcessedINWholeProcess.push(alreadyProcessed); // -- they will be the same , however I would like to separate the idea of the global to the whole script and the use in the recursivity

            //update dataToBeRetrieve with the result

            if(iterator > 0)
            {
                if( !result.stillDataForRetrieving )
                {
                    iterator -- ;
                    nextRecordType = recordTypes[iterator];
                    retrieveData(nextRecordType, alreadyProcessed);

                }
                else
                {
                    alreadyProcessed.length = 0;
                    retrieveData(nextRecordType,alreadyProcessed);
                }
            }

        });
    }

 </script>

      <a href="#" data-role="button" id="retrieveData" >Retrieve Data</a>

</apex:page>

Controlador

public with sharing class RetrieveDataController {

@TestVisible private static Map<String, Schema.SObjectType> m_allObjects = Schema.getGlobalDescribe();

public RetrieveDataController()
 {
 }

/**
 *This is the method that will be called recursively from the "client" simulating the different executes of the batch job.
 *The method will be call till there are no more objectTypes && no more data.
 *Controlling if we need to come back again with a internal variable in a new class Boolean MyDataMap.stillDataForRetrieving
 *
 * Remember to link this method with the correct one and don't create the logic here instead the API- service class. Don't forget Developer X 😉
 *
 **/
 @RemoteAction
 public static MydataMap loadData(String objectType, List<Id> recordIdsAlreadyProcessed)
 {
     //******* objectType continues as string only for prototyping porpuses!!!!*****//
     //*** The correct way is use Schema.sObjectType
     //**** @TestVisible private static Map<String, Schema.SObjectType> m_allObjects =           Schema.getGlobalDescribe();*******//
     //*** Schema.sObjectType tableToQuery = m_allObjects.get( objectType );

     MyDataMap dataMap = new MyDataMap();
     List<DraftObject__c> newRecords = new List<DraftObject__c>();
     List<Id> recordIdsFromRecordTypeAlreadyQueried = new List<Id>();

     // retrieve the data for this object type
     if( objectType == null )
         throw new AppRetrieveException(' No recordtype');

     Schema.sObjectType tableToQuery = m_allObjects.get( objectType );
     if(tableToQuery==null)
        throw new AppRetrieveException(' Incorrect recordtype');

     //In order to don't retrieve the same records twice I set in my query the records already retrieved
List<SObject> records= new List<SObject> ( Database.query('SELECT Id, Name FROM ' + objectType + ' WHERE Id      NOT IN :recordIdsAlreadyProcessed LIMIT 10000') ) ; // The real limit will be 10K

     if(records!=null)
     {
        //update the structure to be returned
        datamap.records.addAll(records);
        //create the draft records from records queried before. Draft will be save in database to don't loose the processed records
        for(SObject record :records)
        {
            /***
            *** here we can process the records
            *** do the calculations and later on set them on the draft object
            ***/

            // you can check here which record type are you using to insert in the correct Lookup field
            /// here I'm going to populate the lookup field that is in Draft__c object
            // DraftObject is going to be linked to the records that are being procesing
            DraftObject__c newDraft ;
            if(objectType == 'Account') //**Remember to use Schema.sObjectType 🙂 !!!
            {
               Id recordId=(Id)record.get('Id');
               newDraft = new DraftObject__c(Account__c = recordId);
               recordIdsFromRecordTypeAlreadyQueried.add(recordId);
            }
            else
            {
               Id recordId=(Id)record.get('Id');
               newDraft = new DraftObject__c(Transaction__c = recordId);
               recordIdsFromRecordTypeAlreadyQueried.add(recordId);
            }

            newRecords.add(newDraft);
         }

         if(!newRecords.isEmpty())
         {
            insert newRecords; // maximum insert 10K
            datamap.drafts.addAll(newRecords);
            datamap.recordIdsAlreadyProcessed.addAll(recordIdsFromRecordTypeAlreadyQueried);
         }
     }

     // check if there is still data to retrieve
     //to check I'll do another query and retrieve 1 record more
     List<SObject> stillAnotherRecord = new List<SObject> ( Database.query('Select Id, Name From '+ objectType + ' WHERE Id NOT IN :recordIdsFromRecordTypeAlreadyQueried LIMIT 1') ) ;

     if(stillAnotherRecord != null && !stillAnotherRecord.isEmpty())
         datamap.stillDataForRetrieving = true;

     return dataMap;
  }

  /**
  * Remember this class is only for Prototype purposes , modify it to make it suitable to your use cases
  */
  public class MyDataMap
  {
     public Boolean stillDataForRetrieving = false;
     public List<SObject> records = new List<SObject>(); // possible data that the JS could need??
     public List<DraftObject__c> drafts = new List<DraftObject__c>(); //possible data that the JS could need
     // however if you don't need it don't use it, might be you only need IDs...
     public List<Id> recordIdsAlreadyProcessed = new List<Id> ();

     public MyDataMap()
     {

     }
  }

  public class AppRetrieveException extends Exception{}
}

Notas finales:

  • Recalcar que en cada llamada proceso 10000 registros con lo cual es más rápido que un proceso batch. Si el número de registros procesados disminuye, el tiempo aumenta. Tenlo en cuenta! 🙂
  • En este momento proceso solamente 60000 registros porque JavaScript remoting tiene una limitación en el tamaño de request – solicitud a cliente donde no pude mandar mas de 1000000 caracteres con lo que nos da alrededor de unos 976K ( a diferencia de los 15 MB de la respuesta- response) y con ese tamaño solo podría procesar unos 66000 Ids. Pero comenté estoy trabajando en la siguiente version donde proceso mas de 60K registros y espero publicarla pronto 🙂

3 comments

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s