miércoles, 9 de abril de 2014

Banco de Mensajería de Ensemble


El banco de mensajería es una producción especial de Ensemble que está preparada para recoger mensajes de otras producciones remotas.

  
La utilidad del banco de mensajería es poder centralizar y unificar la mensajería de diferentes fuentes en un único repositorio de mensajes global.

Los mensajes son copiados (enviados) desde las producciones “cliente” hacia la producción de banco de mensajería. Los mensajes de las producciones origen o cliente no son eliminados después de ser enviados de manera que sigue siendo necesario el purgado de los mismos.

El banco de mensajería puede recibir cualquier mensaje de cualquier origen, sin embargo no dispone en su Namespace de las clases de definición de estos mensajes (excepto las del sistema como EnsLib.HL7.Message o cualquier otra disponible en los paquetes Ens o EnsLib). Para poder incorporar los mensajes personalizados (es decir los que no son del sistema) al banco de mensajería estos son “serializados” en XML. De esta manera la visualización de los mensajes a través del visor de mensajes cambia:


Menú “Ver” estándar

Menú “Ver” banco de mensajería

En el caso del banco de mensajería, al ser una producción tiene sus propios mensajes (como cualquier otra), aunque esto no es muy habitual, si se envían mensajes que no son los manejados por el servicio del banco de mensajería estos se ven desde el visor de mensajes estándar. 

Sin embargo, todos los mensajes que se reciben desde las producciones cliente con objeto de replicación se ven desde la opción Banked Messages, el log de eventos también se replica y se ve desde la opción Banked Events.

Los mensajes que se ven desde la opción de mensajes almacenados (Banked Messages) tiene una visualización algo diferente a la habitual ya que, como se comentó anteriormente, no se dispone de la definición de las clases.



Como se ve en la imagen anterior los mensajes personalizados se presentan serializados. Además existe una columna Client que identifica el Servidor, la Instancia y el Namespace desde el que se envía el mensaje.

Los mensajes incorporados en el banco de mensajería se registran mediante la clase Ens.Enterprise.MsgBank.MessageHeader. Esta clase tiene las siguientes propiedades:
  •  MessageBodyClassName: Nombre de la clase que contiene el mensaje
  • MessageBodyId: Identificador del objeto que contiene el mensaje
  • ClientBodyClassName: Nombre de la clase cuerpo del mensaje en la producción original.
  • ClientBodyId: Identificador del objeto que contiene el cuerpo del mensaje en la producción original.
Siempre que el mensaje original sea personalizado, es decir, no se implemente con una clase del sistema (Ens o EnsLib) entonces MessageBodyClassName se establece a %Stream.GlobalCharacter y el mensaje se serializa en XML. 

Esto es una restricción ya que aunque la visualización es similar a la acostumbrada, la búsqueda de mensajes en función de su contenido se hace muy complicada y no puede hacerse directamente desde el visor de mensajes (a no ser que sean clases HL7).

Producción de Banco de Mensajería

La producción de banco de mensajería ya está construida, puede obtenerse desde la documentación y es una clase que hereda de Ens.Enterprise.MsgBank.Production.. 

A continuación se muestra una producción de ejemplo msgbank.prod.MsgBankProduction:



Como se puede comprobar la producción solo dispone de dos servicios. El servicio MsgBankService se encarga de recibir los mensajes enviados desde las producciones cliente. Escucha en el puerto 9192 por defecto. En este caso adicionalmente se ha establecido la clase msgbank.util.Helper como Bank Helper Class. Esta clase de ayuda es invocada cada vez que se recibe un mensaje y trata el mensaje en el método OnBankMsg.

ClassMethod OnBankMsg(pHeader As Ens.Enterprise.MsgBank.MessageHeader,
                      …) As %Status
{
 if (pHeader.ClientBodyClassName="test.msg.TestMessage") {
   set bodyId = pHeader.MessageBodyId
   set pStream = ##class(%Stream.GlobalCharacter).%OpenId(bodyId)
   do ##class(msgbank.util.TestSearchTable).IndexDoc(pStream)
 }
 quit $$$OK
}

En este método recibimos el objeto de tipo MsgBank.MessageHeader que ha sido almacenado y podemos realizar acciones adicionales sobre el mismo. Por ejemplo podemos realizar la indexación del mismo mediante una clase tabla de búsqueda.

La tabla de búsqueda realiza la indexación sobre un tipo especifico de objetos. En este caso es necesario implementar una clase tabla de búsqueda sobre objetos de tipo %Stream.GlobalCharacter, sin embargo el stream contendrá un XML diferente en función del ClientBodyClassName que se esté almacenando. De esta forma será necesario implementar una tabla de búsqueda diferente para cada tipo de mensaje que queramos.

Producción Cliente

La producción cliente tan solo debe añadir una operación de negocio de tipo Ens.Enterprise.MsgBankOperation y establecer la IP y puerto del servicio de banco de mensajería. Adicionalmente hay que establecer la propiedad Enable Archiving para que los mensajes puedan ser reenviados a la producción del banco de mensajería.


Para facilitar la compresión de las explicaciones he construido dos producciones como ejemplo del banco de mensajería.
  • Una producción cliente sencilla de ejemplo que simplemente coge un fichero en un formato determinado, genera un mensaje, este mensaje pasa del servicio al proceso y del proceso a la operación (Descargar). También añado un sencillo fichero de entrada de ejemplo (Descargar).
  • La otra producción es la del banco de mensajería que almacena los mensajes reenviados desde la producción original. En esta producción se utiliza una clase de ayuda y una clase de tabla de búsqueda personalizada para el caso del mensaje especifico de la producción ejemplo (Descargar).
La producción cliente se despliegue en un servidor/namespace.
La producción de banco de mensajería se despliega en otro namespace o en otro servidor/namespace

En el namespace de banco de mensajería podrás ver todos los mensajes de la producción cliente y además podrás utilizar la tabla de búsqueda desde el visor de mensajes almacenados.

Espero que sea de utilidad

miércoles, 22 de mayo de 2013

Estrategias de Alta Disponibilidad


En general existen 3 estrategias de alta disponibilidad con Caché/Ensemble:

  • Cluster en Fail-Over de Sistema Operativo
  • HA Basada en Virtualización
  • Caché Database Mirroring

Cada una de ellas tiene una serie de diferencias, pros y contra dependiendo de la infraestructura y sistemas

Cluster Fail-Over
Consiste en utilizar soluciones de Fail-Over proporcionadas a nivel de sistema operativo.
Dos servidores idénticos compartiendo:

  • un dispositivo de almacenamiento (SAN)
  • una dirección IP.

Un servidor (activo) ejecuta el trabajo y el otro permanece en Standby (pasivo) preparado para la transición. Cuando se produce un corte de servicio en el servidor activo el controlador del cluster transfiere el control de la IP y del disco al nodo pasivo y posteriormente inicia los servicios incluyendo Caché.

Análisis de pros y contras:
Transición tras caída de la máquina física por perdida de corriente o similar: 
Se maneja de forma fluida 
Transición por Fallo o Corrupción en almacenamiento: 
Al utilizar un almacenamiento compartido no es posible la transición 
Transición por caída de Caché: 
Puede configurarse pero depende del sistema operativo 
Tiempo de recuperación en transición: 
Pueden ser minutos 
Sincronización de ficheros externos: 
Todos los ficheros están disponibles por ambos nodos 

Alta disponibilidad basada en virtualización
Tecnologías de virtualización como VMWare ESX, proporcionan capacidades de HA. Monitorizan la salud y viabilidad del HW así como de los OS en ejecución. En caso de fallo, el SW de HA automáticamente reinicia la VM que falló en un HW alternativo


Análisis de pros y contras:
Transición tras caída de la máquina física por perdida de corriente o similar: 
Maneja de forma fluida tanto la caída de la física como de la virtual 
Transición por Fallo o Corrupción en almacenamiento: 
Al utilizar un almacenamiento compartido no es posible la transición 
Transición por caída de Caché: 
Puede configurarse pero depende del sistema de VM 
Tiempo de recuperación en transición: 
Pueden ser minutos 
Sincronización de ficheros externos: 
Todos los ficheros están disponibles por ambos nodos 

Mirroring
Un mirror consiste en dos sistemas físicos Caché independientes, llamado failover members. Uno de los miembros tiene el rol de primario, el otro tiene el rol de backup. Los datos se replican desde el primario hacia el backup Es necesario utilizar una dirección IP virtual que es compartida por ambos nodos


Análisis de pros y contras:
Transición tras caída de la máquina física por perdida de corriente o similar: 
Por defecto no hay fail-over si el nodo primario no es alcanzable, en otro caso requiere planificación y configuración 

Transición por Fallo o Corrupción en almacenamiento: 
Utiliza almacenamiento independiente y replicación a nivel lógico de manera que resuelve muchos casos de corrupción 

Transición por caída de Caché: 
Detección rápida y muy configurable 

Tiempo de recuperación en transición: 
Pueden ser segundos 

Sincronización de ficheros externos: 
Solo las bases de datos son replicadas automáticamente no los ficheros externos 

Conclusiones
Si podemos manejar la carga de todo el sistema en uno o dos servidores es razonable configurar nodos en fail-over. Pero si se necesitan ocho servidores, entonces duplicarlos puede resultar demasiado caro comparado con N+X

La decisión sobre la configuración depende del tipo de las transacciones que serán manejadas (integridad transaccional, secuencialidad, etc.). Con todo lo anterior se pone de manifiesto la necesidad de realizar análisis y pruebas de capacidad previos al posible despliegue

jueves, 31 de enero de 2013

JobPerConnection y PoolSize

En una entrada anterior hablamos del tamaño de grupo o PoolSize (http://blogdeensemble.blogspot.com.es/2011/06/aclarando-el-concepto-tamano-de-grupo.html) ahora hablamos de otro setting que está relacionado.

En el caso especial de los adaptadores basados ​​en TCP (TCP, HTTP, SOAP, etc) el valor del setting PoolSize tiene un comportamiento ligeramente diferente al habitual y depende de la configuración de JobPerConnection.

Si el setting JobPerConnection se establece en "false" hay uno y sólo un listener (BusinessService Job) para cualquier valor de PoolSize mayor que "0" (PoolSize igual a "0" impediría que este adaptador funcionase).

Si el setting JobPerConnection se establece en "true" y el setting PoolSize es mayor que "1", este sirve como un límite en el número de jobs de conexión simultáneos que pueden crearse. En el caso de que se alcance el límite de jobs, el listener no aceptará ninguna conexión más hasta que uno de los jobs de conexión existentes termine o muera. Adicionalmente, en el caso de JobPerConnection = "true" el valor "1" en PoolSize se usa para posibilitar la creación ilimitada de nuevos jobs (excepto el límite puesto por el sistema operativo).

El setting QSize es importante en este caso porque si Qsize es igual a "0" Ensemble no permitirá que el sistema operativo acepte (encole) una conexión si el listener ha aceptado ya una conexión.

En general la configuración por defecto JobPerConnection="true", PoolSize="1" y Qsize=100 siempre nos vendrá bien.

Lo gracioso es que todo esto no viene en la ayuda. Al menos hasta la versión actual 2012.2. Así que si habeis llegado aqui buscando más información sobre esto. Enhorabuena !!

viernes, 19 de octubre de 2012

Controlar tamaño de CACHE.DAT y Journals en Ensemble


En primer lugar hay que entender como funciona y se maneja la base de datos de Caché, eso esta descrito en este capitulo de la documentación (http://docs.intersystems.com/ens20121/csp/docbook/DocBook.UI.Page.cls?KEY=GSA_manage#GSA_manage_databases). 

Caché aumenta el tamaño conforme lo va necesitando, cuando en el CACHE.DAT ya no queda espacio entonces se aumenta el tamaño del fichero y lo hace en trozos del 12% del tamaño actual de la base de datos (o en 10 MB dependiendo de lo que sea más grande). Por ejemplo, si tu base de datos ocupa 1 GB (1.024 MB) entonces cuando le toca crecer lo hará en 124 MB aproximadamente.

El fichero CACHE.DAT nunca decrece, es decir, nunca reduce su tamaño de forma automática. De esta forma, si por ejemplo recibes 10.000 mensajes de 10 KB cada uno es posible que necesites más de 100 MB para almacenarlos, si como en el caso anterior la base de datos crece en 124 MB, entonces tienes un restante de 24 MB. Pero imagina que luego se purgan esos mensajes, entonces el espacio se libera pero el fichero no decrece en tamaño y se quedará con un tamaño de 1.148 MB pero con 124 MB de espacio libre. Esto seguirá así hasta que sea necesario crecer de nuevo.

Siempre es posible "truncar" y/o "compactar" el espacio para reducir el volumen del fichero CACHE.DAT. Es por esto que las bases de datos de Ensemble se suelen mantener en un tamaño "constante".

Por otro lado está el journal (http://docs.intersystems.com/ens20121/csp/docbook/DocBook.UI.Page.cls?KEY=GCDI_journal). Los ficheros de journal son unos ficheros muy simples que registran cada una de las operaciones que se realizan de forma atómica en los globals. Recuerda que finalmente uses objetos o SQL la información reside en los globals. Las operaciones en los globals son SET y KILL y si miras el contenido del global desde el Operaciones >> Journal veras algo como:

Off
Hora
 Proceso 
Tipo

 Global
Base de datos
13..
20..
25.. 
KILL 

 ^SYS("Task","TaskI","ScheduleIndex",62748,0,0,1) 
/Users/ensemble/mgr/ 
13.. 
20.. 
25.. 
SET 

^SYS("Task","TaskI","ScheduleIndex",62749,0,0,1) 
/Users/ensemble/mgr/ 
13.. 
20.. 
25.. 
SET 

^SYS("Task","TaskAttemptCount",5) 
/Users/ensemble/mgr/ 
13.. 
20.. 
25.. 
SET 

^SYS("Task","TaskD",1) 
/Users/ensemble/mgr/ 

Como ves refleja cada uno de los SET y KILL en cualquier base de datos de la instancia.

Los journals se utilizan como mecanismo de recuperación ante desastre. De hecho cuando Caché se recupera de una caída el automáticamente aplica de forma redundante las entradas existentes en el JOURNAL que hayan podido quedar pendientes por estar en memoria o en el WIJ (http://docs.intersystems.com/ens20121/csp/docbook/DocBook.UI.Page.cls?KEY=GCDI_wij). Además se usan para otras estrategias de replicación o de restauración.

En cualquier caso, y como te puedes imaginar, el journal, crece, crece y crece. El journal se compone de ficheros, normalmente limitados a 1GB como máximo, sin embargo hay una tarea que de manera automática cambia el fichero del journal cada día. De manera que puedes encontrarte con muchos ficheros de menos de 1GB (cambio por tiempo) o de 1GB (cambio por tamaño).

Los ficheros de journal no son necesarios eternamente. Por ejemplo si has hecho un Backup los journal hasta la fecha del Backup se suelen eliminar porque ya no recuperas del journal sino del Backup. Por lo tanto, normalmente hay una tarea programada que purga (elimina) los ficheros de journal que ya no son útiles. También se puede realizar esta tarea con la rutina PURGE^JOURNAL desde el namespace %SYS.

Mucho crecimiento en journal describe mucho movimiento en base de datos, pero ese movimiento puede ser igualmente repartido en SET y KILL de manera que la base de datos se queda igual. Por otro lado es normal no ver crecimiento en la base de datos porque es posible que el espacio libre internamente sea bastante.

El consumo de disco por parte de los journals no se puede evitar. Pero se puede gestionar, es decir, puedes purgarlos más a menudo tratando de conservar el espacio consumido. Esto se puede hacer ejecutando la tarea de purgado más a menudo o con unos criterios más relajados. En la opción de configuración de sistema >> configuración de journal puedes editar esos criterios de borrado.

Espero que sea de utilidad

martes, 9 de octubre de 2012

Configuración de la seguridad de DeepSee y Ensemble


La guía para la configuración de seguridad de DeepSee está en:

En general la seguridad en Ensemble 2012 sigue este modelo.

1. Tenemos Recursos. Los recursos dan acceso a funcionalidades.
2. Tenemos Roles. Los roles tienen privilegios de uso sobre Recursos.
3. Tenemos Usuarios. Los usuarios pueden estar asignados a varios Roles.

Para que un usuario pueda acceder al portal de usuario de DeepSee (http://host:57772/csp/NAMESPACE/_DeepSee.UserPortal.Home.zen) entonces debe estar asignado como mínimo a roles que tengan privilegios sobre los siguientes recursos:
  • %DeepSee_Portal
  • %Ens_Portal
  • Recursos asociados a las base de datos incluidas en los cuadros de mando
Cada base de datos debe tener un recurso.
Para crear un recurso para la base de datos ir a Administración >> Configuración >> Configuración de Sistema >> Bases de Datos Locales
Editar la base de datos y donde pone "Nombre de recurso" elegir "Crear un nuevo recurso". Podemos elegir si es de solo lectura o R/W.

Si por ejemplo tenemos la base de datos HELLO y creamos un nuevo recurso para ella entonces se creará un recurso %DB_ HELLO. Pero además se crea automáticamente un Rol %DB_ HELLO que tiene privilegios sobre el recurso y que podemos usar a nuestra elección. De esta manera para que un usuario pueda acceder al portal de DeepSee de la base de datos HELLO tendrá que estar asignado a Roles que como mínimo den privilegio de acceso a los siguientes recursos:
  • %DeepSee_Portal
  • %Ens_Portal
  • %DB_ HELLO
La organización de Roles puede varias, por ejemplo puedes tener:

Rol DeepSeeSimpleUser con Privilegio de acceso a los recursos (%DeepSee_Portal, %Ens_Portal)
Rol %DB_ HELLO (por defecto) con Privilegio de acceso a (%DB_ HELLO)

Y entonces tener un usuario David asignado a ambos roles

O podrías tener un Rol DeepSeeHelloUser con Privilegio de acceso a los recursos (%DeepSee_Portal, %Ens_Portal, %DB_LABCORE) 
Y entonces solo necesitas asignar un Rol al usuario (aunque es menos flexible)

En definitiva la cuestión es disponer de los recursos necesarios a través de los Roles.

En el portal de seguridad de Caché disponemos de los siguientes recursos que dan acceso a cada una de las características de DeepSee:
%DeepSee_Analyzer
%DeepSee_AnalyzerEdit
%DeepSee_Portal
%DeepSee_PortalEdit
%DeepSee_Architect
%DeepSee_ArchitectEdit
%DeepSee_Admin

Igualmente para Ensemble tenemos un montón de recursos como:
%Ens_Alerts
%Ens_ArchiveManager
%Ens_Code
%Ens_Credentials
%Ens_Deploy
%Ens_DeploymentPkg
%Ens_DTLTest
%Ens_EDISchema
%Ens_Jobs
%Ens_LookupTables
%Ens_MessageContent
%Ens_MessageDiscard
%Ens_MessageEditResend
%Ens_MessageResubmit
%Ens_MessageSuspend
%Ens_MsgBank
%Ens_MsgBankConfig
%Ens_MsgBank_MessageContent
%Ens_MsgBank_MessageEditResend
%Ens_ProductionConfig
%Ens_ProductionDocumentation
%Ens_Purge
%Ens_PurgeSchedule
%Ens_Queues
%Ens_SequenceManager
%Ens_SystemDefaultConfig
%Ens_TestingService
%Ens_ViewFileSystem
%Ens_WorkflowConfig

Por último en el listado de usuarios hay un link "Perfil" que te da el detalle de todos los recursos que dispone el usuario y a través de que Rol.

Espero que sea de utilidad

Indices en Ensemble


Cuando en Caché creamos una clase o editamos una clase y añadimos un:

Index NameIDX On Name;

Lo que hacemos es indicarle al planificador de ejecución de SQL de Caché que tome en cuenta una nueva "ruta" para obtener los valores de la clase. Así si la clase es:

Class MyApp.Student Extends %Persistent 
{
 Property Name As %String;
 Property GPA As %Float;

 Index NameIDX On Name;
}

Y ejecutamos una consulta SQL como:

select * from MyApp.Student where Name like 'David'

Entonces el plan de consulta es:

Read index map MyApp.Student.NameIDX, using the given %SQLUPPER(Name), and looping on ID.
For each row:

Read master map MyApp.Student.IDKEY, using the given idkey value. 
Output the row. 

Como podemos observar el planificador va a utilizar un camino que es utilizar en primer lugar el "index map" MyApp.Student.NameIDX. Como su nombre indica es un "map" un mapa o una dirección al camino de búsqueda, ahora bien, está es la parte de definición, luego está la parte de los datos. 

Un indice utiliza de forma estándar el global ^FullClassNameI para almacenar los valores de indexación. Así por ejemplo el global de indexación para la clase anterior es ^MyApp.StudentI. 

MUY IMPORTANTE: Cuando definimos el indice en la clase y compilamos, a partir de ese momento estamos diciendole al planificador que puede utilizar ese indice pero eso no significa que el indice contenga datos. Si, por ejemplo, tenemos una tabla que dispone de 100 filas y definimos un indice en la clase el planificador inmediatamente lo va a usar pero al usarlo encontrará siempre 0 registros ya que el global de indexación para ese indice estará vacío.

En los casos en los que partimos de una tabla vacía, de manera automática cada vez que se realiza un INSERT (o un %Save()) se añade una entrada al global de indexación. Por ejemplo para la sentencia:

insert into MyApp.Student (Name, GPA) values ('David',95.4)

El global de indexación queda como sigue:

USER>zw ^MyApp.StudentI
^MyApp.StudentI("NameIDX"," DAVID",1)=""

Esto quiere decir que para el valor "DAVID" el ID asociado es el 1.

Si la clase MyApp.Student dispone de 100 filas antes de la creación del indice el global estará vacío y no encontrará a nadie cuando se ejecute una sentencia que utilice el indice. Para poder generar el indice para todas las filas existentes se debe invocar al método %BuilIndices. Este método tiene 3 parámetros, el primero es la lista de indices a generar, si se deja vacío genera todos los indices existentes en la clase. El segundo parámetro indica si se debe borrar las entradas para ese indica en el global previamente a la construcción. El tercer parámetro indice si se debe bloquear el Extent (global) durante la construcción. 

De esta forma podemos tener estas sentencias:

// Genera todos los indices sin borrar los valores anteriores
set tSC=##class(MyApp.Student).%BuildIndices()

// Genera todos los indices borrando los valores anteriores
set tSC=##class(MyApp.Student).%BuildIndices(,1)

// Genera solo el indice "NameIDX" borrando los valores anteriores
set tSC=##class(MyApp.Student).%BuildIndices($lb("NameIDX"),1)

// Genera los indices "NameIDX" y "TestIDX" sin borrar los valores anteriores
set tSC=##class(MyApp.Student).%BuildIndices($lb("NameIDX","TestIDX"))

SIEMPRE se debe consultar el valor devuelto por tSC para comprobar que el indice se ha generado correctamente.

Otra situación posible es que "borremos" o "modifiquemos" un indice. Por ejemplo tenemos el indice:

Index indiceA On campoA;

Y queremos reemplazarlo por el indice:

Index indiceB On (campoA,campoB);

La secuencia correcta sería:

1. Purgar los valores del indice "indiceA" en el global de Indexación
2. Modificar la clase eliminar la definición del "indiceA" y añadir la del "indiceB"
3. Costruir el "indiceB"

Para purgar un indice se utiliza el método %PurgeIndices. Este método dispone de 2 parámetros, el primero es la lista de indices a purgar, si se deja vacío purga todos los indices existentes en la clase. El segundo parámetro indica si se debe bloquear el Extent (global) durante el purgado. 

// Purgar el indice "NameIDX"
set tSC=##class(MyApp.Student).%PurgeIndices($lb("NameIDX"))

Por último, todo esto está bien descrito en este link de la documentación que os pido encarecidamente que leáis:

sábado, 2 de junio de 2012

Academia: Usando DeepSee&iKnow para analizar todos los datos


Hola,

Para todos aquellos que no pudisteis asistir a esta academia que se impartió durante el Simposio 2012 de INTERSYSTEMS del pasado Mayo y que estais interesados en conocer más acerca de iKnow y DeepSee os dejo el paso a paso y los ficheros necesarios en los siguientes enlaces.


Esta academia ayuda a desarrollar una serie de ejercicios con el ánimo de demostrar las capacidades de iKnow embebido en DeepSee. A través de ejercicios prácticos se demuestra cómo usar las nuevas características de iKnow para DeepSee y combinar tanto datos estructurados como datos no estructurados para propósito de análisis.


Recordaros que para poder realizar la academia debéis descargar la versión de Ensemble 2012.2 Field Test 3 build 571, disponible desde:
https://developer.intersystems.com/download/

Los ficheros de fuentes necesarios se descargan desde:
https://docs.google.com/open?id=0B3mMOmL380ZJZkNhXzNVMTFuUlE

El paso a paso se descarga desde:
https://docs.google.com/open?id=0B3mMOmL380ZJOGU3LVhFenZEZmc

Espero que lo disfruteis !!!

Cualquier cuestión no dudéis en contactarme (david.reche@intersystems.com)