Cambio en paralelo de un Event Bus

Una de las cosas en las que estamos trabajando desde hace unas semanas en el equipo de producto de Sigma Rail es en cambiar el bus de eventos, algo con lo que aún no hemos terminado porque está siendo un cambio en paralelo que vamos haciendo progresivamente.

Originalmente empezamos con una implementación de Event Bus en memoria por simplicidad, lo cual nos sirvió para tener un diseño que nos permitía desacoplar fácilmente lógica de los casos de uso con los side-effects que deben desencadenar.

Uno de esos side-effects es la generación de thumbnails de las imágenes que suben los usuarios a nuestra plataforma, ahora mismo se generan 4 thumbnails distintos que luego terminan persistiéndose en Amazon S3. Teniendo en cuenta que las imágenes de los usuarios pesan bastante y que lo normal es que se suban varios gigas de imágenes de golpe, resultaba en que se empeoraba la experiencia de usuario al tardar más en finalizar la subida de ficheros y se exigía usar instancias con más recursos, y obviamente más caras. De modo que se hacía patente que había que empezar a resolver ese tema con una solución realmente eventual.

Qué es un Event Bus

El bus de eventos es como solemos llamar al intermediario responsable de hacer llegar los eventos que genera un componente publisher para que otros componentes subscribers los reciban, con algún tipo de implementación basada en el patrón Pub-Sub.

Ese patrón lo utilizamos cuando queremos evitar que un componente que genera eventos esté acoplado a quienes deben consumirlo.Hacemos que ese componente envíe los eventos que genera al bus de eventos de dominio, y este bus informa de esos eventos a los componentes que se hayan suscrito al respectivo tipo de evento.

Esquema representando un event bus

Qué es hacer un cambio en paralelo

Según Danilo Sato en la web de (san) Martin Fowler:

Parallel change, also known as expand and contract, is a pattern to implement backward-incompatible changes to an interface in a safe manner, by breaking the change into three distinct phases: expand, migrate, and contract.

Lo que viene siendo introducir cambios retro compatibles haciendo baby steps que no rompan una interface existente hasta que todos los clientes de esa interface no se hayan adaptado.

En este ejemplo vamos a romper la interface existente porque vamos a pasar a una ejecución en el mismo proceso a en distintos workers de forma distribuida. Además como parte de cada uno de los cambios integramos con la línea principal de desarrollo y desplegamos a producción (obviamente antes en desarrollo, stage, etc).

Esto es porque queremos evitar hacer grandes reescrituras y despliegues a modo big-bang, ir haciendo cambios razonablemente pequeños y hacer una entrega continua que nos ayude a reducir riesgos y detectar posibles problemas pronto.

Personalmente me gustó mucho el taller que preparó Eduardo Ferro sobre el tema, muy recomendable para practicar en un entorno controlado.

La interface EventBus

Como lenguaje de programación que usamos es Python y tenemos la convención de equipo de usar type hints siempre que sea posible; para definir la interface usamos una clase base EventBus que define métodos para añadir un subscriber (que a su vez utilizan una clase base Subscriber). En otros lenguajes hubiéramos usado interfaces frente a clases abstractas.

La interface de EventBus es sencilla: Expone la posibilidad de publicar eventos, añadir suscriptores al bus y limpiar todos los suscriptores del bus. Mientras que de los suscriptores se espera una tupla con los nombres de los eventos que le interesan y se espera que sean callable.

La clase abstracta EventBus queda algo así:

class EventBus(ABC):
   @abstractmethod
   def publish(self, events: List[DomainEvent]):
       pass
 
   @abstractmethod
   def add_subscriber(self, subscriber: Subscriber):
       pass
 
   @abstractmethod
   def clean_subscribers(self):
       pass

Mientras que la clase abstracta Subscribers es algo como:

class Subscriber(ABC):
   @abstractmethod
   def __call__(self, event: dict):
       pass
 
   @abstractmethod
   def subscribed_to(self) -> Tuple[str, ...]:
       pass

Punto de partida, implementación en memoria

Una implementación de Event Bus en memoria es muy naive, pero funciona a pequeña escala o cuando tienes side-effects muy ligeros.

Lo bueno es que como comentaba al inicio, nos habilita a diseñar nuestro software de forma eventual aunque realmente no lo sea, sin tener que introducir nada a nivel de infraestructura y añadir complejidad operacional. Lo malo es que todo ocurre en la misma máquina y petición, así que nos perdemos el habitual beneficio de este tipo de diseños de tener mayor facilidad de escalabilidad horizontal y mejorar el rendimiento/UX.

En nuestro caso, esta implementación InMemoryEventBus utiliza un diccionario para guardar todos los suscriptores que se le vayan añadiendo a add_subscriber, mientras que a la hora de publicar con publish lo que ocurre es que se recorre los eventos recibidos y se comprueba si los suscriptores están subscribed_to a cada unos de los type_name de los eventos para llamar a su __call__ si es así. Y tenemos una serie de tests automáticos con escenarios que comprueban cuando sí o no se recibe la llamada a un doble que actúa como suscriptor.

A tener en cuenta que, aunque todo ocurra en memoria, simulamos el serializar y deserializar los eventos para que el día que se cambie a otro adapter que requiera de infraestructura estar seguros de que los suscriptores no necesiten modificar su código. Por eso en nuestro caso el publish transforma el evento en un diccionario que es lo que los Subscriber esperan. De un modo similar en una aplicación web/http recoge todo el contenido del cuerpo de la request. También os digo que posiblemente podríamos haber envuelto el contenido del diccionario en algún objeto propio para esconder ese detalle de implementación.

Este sería el punto de partida antes de empezar con el cambio en paralelo.

Implementando el adapter para SQS

Al necesitar que ocurran cosas de forma realmente eventual nos decidimos por usar SQS, manteniendo todo en el mismo repositorio y buscando maneras de desplegar workers que ejecuten los distintos subscribers para poder escalar de forma independiente unos de otros.

El primer paso fue implementar el adapter de SQSEventBus usando los mismos escenarios de tests que los de memoria. Aunque tuvimos que introducir algún pequeño cambio en la implementación de esos tests, ya que había que hacer una llamada explícita al consumo de mensajes de la cola SQS.

La implementación es más elaborada en este caso. En add_subscriber se crea una cola específica en SQS por subscriber y se mantiene también la referencia de cada suscriptor en un diccionario. En este caso el publish elige a qué cola SQS publica el evento con la misma comprobación sobre el subscribed_to, esto es porque queremos añadir sólo los eventos en cada cola está interesada en consumir.

Todo esto lo he resumido como un paso, pero podemos haberlo ido integrando en la línea de desarrollo principal y desplegado en varios. De momento con que pase los tests nos vale que de momento no se va a ejecutar nada de este código en producción.

Implementando un adapter para publicar eventos simultáneament en memoria y SQS

El siguiente paso que queremos dar, es ver que seamos capaces de que ese adapter que hemos implementado publica los eventos en mensajes SQS. Sólo verlo, sin afectar al resto del comportamiento.

Para ello decidimos crear un HybridEventBus, que es un nuevo adapter que combina ambas implementaciones recibiéndolas en el constructor, en este paso simplemente llama a las implementaciones que compone. Con esto podría habernos valido para validar la publicación, pero no queremos crear demasiadas colas SQS por entorno de momento y decidimos utilizar sólo el suscriptor ThumbnailCreator que es el primero que queremos migrar.

Quedando el add_subscriber algo como

def add_subscriber(self, subscriber: BaseSubscriber):
   self.__in_memory.add_subscriber(subscriber)
   if isinstance(subscriber, ThumbnailCreator):
       self.__sqs.add_subscriber(subscriber)

Para testear automáticamente nos basamos en los tests anteriores comprobando que cuando se recibe un evento esperado por un doble que herede de ThumbnailCreator, éste se llama 2 veces.

De nuevo podemos integrarlo y desplegarlo, aún no se ejecuta nada de SQS en producción. En el siguiente paso es cuando empieza.

Publicación de eventos simultánea en memoria y SQS

En nuestro caso, tenemos centralizada la inyección de dependencias, de modo que cambiando de ahí la implementación todos los que reciben esa dependencia pasan a utilizar la implementación híbrida.

De:

def event_bus(self):
   return InMemoryEventBus()

A:

def event_bus(self):
   return HybridEventBus(self.in_memory_event_bus(), self.sqs_event_bus())

Eso ya sí impacta en el código de producción, así que tras integrarlo y desplegarlo se que comprueba que todo sigue funcionando correctamente y que, aunque aún no consumimos los eventos de SQS, podemos validar que se crean las colas esperadas y que están llegando los tipos de eventos que esperamos en ellas.

Consumir eventos de memoria y de colas SQS

Una vez viendo que hay mensajes que representan nuestros eventos en las colas, el siguiente paso es empezar a consumirlos. Así que se abre por fin el melón de los workers, que en nuestro caso desplegamos en Amazon Elastic Beanstalk.

Resumen rápido de cómo funciona el happy path:

  • A Elastic Beanstalk le configuras la url de la cola que debe escuchar y un endpoint http, ese endpoint recibirá vía POST el contenido de los mensajes de esa cola
  • El propio entorno provee de un SQS Dameon que se encarga de leer mensajes de la cola y mandarlos a ese endpoint
  • Si va todo ok y devolvemos en ese endpoint un status 200, SQS Dameon borra el mensaje de la cola

Para entender mejor cómo funcionaba me resultó bastante aclarador el post AWS Elastic Beanstalk Worker environment deep dive.

Así que lo que tenemos es un único endpoint para recibir esos mensajes como puerta de entrada de los mensajes, a partir de ahí buscamos qué suscriptor es el que debe consumir el evento que contiene el mensaje por una variable de entorno SUBSCRIBER_NAME, esa información también la configuramos para que aparezca en los logs. Ese endpoint podemos dejarlo testeado automáticamente y de nuevo integrar y desplegar, aún no se ejecutará en producción.

Aprovisionamiento de workers como suscriptores

El siguiente paso es el aprovisionamiento de esos entornos, que lo que hemos terminado haciendo a nivel de AWS Elastic Beanstalk es crear un entorno tipo worker nuevo por cada subscriber.

Sin entrar en detalles por no desviarme mucho de tema, en nuestro caso optamos por provisionar esos entornos y desplegar programáticamente cuando arranca el entorno principal usando el SDK oficial en python de AWS. Tiene limitaciones pero de momento es la mejor opción que hemos encontrado.

Así que una vez implementada la parte de aprovisionamiento al arrancar la aplicación ya tenemos workers consumiendo los mensajes de las colas SQS.

Por la implementación de HybridEventBus se va a llamar a ThumbnailCreator una vez por estar suscrito en memoria y otra por estarlo en SQS, como es una operación idempotente no hay gran problema con que de momento se genere 2 veces. Así que tras integrar y desplegar, podemos validar a través de los logs que se está ejecutando correctamente el subscriber dentro del worker.

Pasando a consumir sólo de colas SQS

Ahora ya que tenemos validado que se generan los thumbnails vía los nuevos workers toca empezar a hacer limpieza. Tenemos que quitar ese subscriber de la ejecución en memoria para que sea realmente eventual.

Modificando ligeramente HybridEventBus (y sus tests), podemos hacemos que no se añada el subscriber para el bus de eventos, sólo en el de SQS:

def add_subscriber(self, subscriber: BaseSubscriber):
   if isinstance(subscriber, ThumbnailCreator):
       self.__sqs.add_subscriber(subscriber)
   else:
       self.__in_memory.add_subscriber(subscriber)

Una vez más se vuelve integrar y desplegar, comprobando que todo continua correcto.

En este último paso tenemos que tener en cuenta que hemos cambiado el comportamiento del sistema:

  • Lo más evidente es que las peticiones de subida responden más rápido
  • Se ha ganado en resiliencia, si la generación del thumnbail falla la petición de subida ya no lo hace. El mensaje quedará en la cola de nuevo y se reintentará generar el thumbnail en otro momento
  • Precisamente aunque la acción de subida se haya ejecutado con éxito el thumbnail no estará disponible inmediatamente, cosa que en este caso puede llegar a impactar en la interfaz de usuario, debemos contar con ello

Este es un trabajo que todavía no hemos finalizado, tenemos algunos suscriptores más que iremos migrando poco a poco hasta que podamos borrar tanto HybridEventBus como InMemoryEventBus como pasos finales.


Aunque seguir esta práctica pueda dar la sensación de ir más lentos es algo que reduce el riesgo, podemos equivocarnos también pero sin tener que desechar tanto trabajo y puede ayudarnos a darnos cuenta antes si estamos en un callejón sin salida. No tenemos ramas sin integrar durante muchos días o incluso semanas que luego generan conflictos dolorosos, eso también facilita que en caso de aparcarlo por otras prioridades podamos retomarlo con mucha más facilidad.

La parte negativa es que, hasta que el cambio no está hecho, tenemos más complejidad en el código y deuda técnica. Así que cuidado con que esos cambios se eternicen.

Documental sobre Squads

Squads es un documental de Invision sobre el popularizado término squads, en el que se explica su origen y se muestran unas pinceladas de cómo trabajan algunos equipos que crean productos de digitales.

Mi resumen sobre el término sería algo como tener equipos multidisciplinares donde se mezclan personas con habilidades complementarias que estén alineadas para alcanzar un objetivo común, o como mínimo aspirar a ello.

El documental se centra principalmente en la parte de descubrimiento y diseño de producto (siendo Invision una compañía de diseño tampoco sorprende :)). Aparecen personas de compañías conocidas mundialmente como Atlassian, Airbnb, Youtube… y algunas startups de menor tamaño. Además de Jeff Sutherland, uno de los creadores de Scrum; y Henrik Kniberg, el de los famosos vídeos de Spotify Engineering Culture que tanto se viralizaron y terminaron en el boom del “modelo spotify” que se ha intentado replicar tantas veces, al parecer sin demasiado éxito.

Creo que es un documental interesante para cualquiera que trabaje en el mundo de producto digital, sea cual sea su perfil. Aunque también creo que idealiza y simplifica el conseguir trabajar de ese modo.

Digo esto porque, aunque creo que tener equipos multidisciplinares es la mejor estrategia para construir producto (que los llamen squads, células, scrum teams… es irrelevante), no es algo que ocurra diciéndole a un grupo de personas que tienen que trabajar en lo mismo y eso haga que se alineen mágicamente. Hay que trabajarlo e iterarlo, y aún así puede que nunca se consiga convertir a un grupo de personas en un equipo.

Píldora. Acceder a variables de entorno desde plantillas Thymeleaf

Es una buena práctica depender de variables de entorno para las configuraciones, tanto secretos como cualquier otra configuración que puede cambiar entre los entornos de despliegue de nuestro software. De ese modo tenemos control granular de cada configuración en cada despliegue que hagamos, y en caso de tener que cambiar alguna configuración no es necesario hacer ningún commit en nuestro repositorio.

A veces es algo que hay que montar un poco más a mano, pero trabajando con Spring Boot es algo que ya está resuelto y da flexibilidad al respecto. A nivel del código de infraestructura en código dependiente de Spring simplemente usando la anotación @Value ya nos inyectará el valor que haya en el application context.

Lo que hasta hace poco no había tenido que resolver es acceder a esas variables de entorno directamente desde plantillas de Thymeleaf, en este caso para evitar tener hardcoded la configuración de Firebase Authentication.

Buscando información encontré que, como parte de la integración con Spring, es posible acceder a los beans del application context utilizando @ en las plantillas.

Así que podemos acceder al bean que representa el Environment, por ejemplo:

${@environment.getProperty("FIREBASE_API_KEY")}

Un año ayudando a equipos

Hace poco más de un año hice público que empezaba a dar servicios de consultoría para ayudar equipos, sin mucha más pretensión que por un lado ordenar mis ideas sobre los servicios que quería empezar a dar y por otro que la gente que conozco supiera de ello.

Al publicar el post tuvo muy buena acogida, infinitamente mejor de lo que hubiera imaginado nunca. Me surgieron bastantes leads de ahí, incluso un par de tentadoras ofertas para incorporarme a startups muy prometedoras que preferí declinar en ese momento.

Como es normal, de esos leads muchos no se concretaron. Ya fuera por mi parte, por la de las compañías interesadas o por ambas; no terminamos de encontrar encaje. Pero ese post sirvió perfectamente para el propósito de que gran parte de la gente que conozco me tuviera en mente.

Tipos de clientes

Durante este año he podido trabajar con algo más de una decena de empresas de distintos tamaños, que podrían agruparse en estos sectores:

  • Impresión industrial
  • Logística
  • Consultoría tecnológica
  • Alquiler de vehículos
  • Moda
  • Certificación de autenticidad
  • Medios digitales

Con contextos de lo más variopintos, desde fundadores de startups a la búsqueda de su market fit aún sin equipo técnico, hasta equipos dentro de multinacionales involucrados en grandes cambios tecnológicos y organizativos.

Formatos de las colaboraciones

En este año en algunas ocasiones aunque a veces pudiera haber buen feeling inicial sobre posibles colaboraciones, no encontraba un formato de colaboración más allá de incrustarme en los equipos unos meses. Cosa que no podía hacer tras incorporarme en Sigma Rail actuando como tech lead la mayor parte de mi tiempo.

Descartada la opción de incrustarme en equipos, tenía pensados dos formatos: Mentorías y formaciones a equipos, son dos tipos de colaboraciones que ya había hecho en el pasado varias ocasiones y con las que me sentía cómodo. A ese tipo de colaboraciones se le unió pronto el hacer asesorías, que a diferencia de las mentorías los objetivos tienden a tener un impacto más cercano al nivel organizacional, normalmente para apoyar a roles con respondabilidades de gestión.

Las formaciones no tienen mucho misterio. Procuro que sean de no más de 2 o 3 días y que no duren mucho más de media jornada, la gente tiene otras cosas que hacer aparte de estar en tus sesiones. También lo que hago es adaptar cada formación a la realidad de cada cliente, incluso las diseño de cero si es necesario. Si es para un equipo que trabaja conjuntamente procuro que buena parte del trabajo sea sobre su propio código, para facilitar llevar a su día a día lo que hayamos estado tratando.

En cuanto a las mentorías normalmente tendemos a cerrar una bolsa de sesiones inicial que puede ir renovándose si estamos todos a gusto con ello. Combinamos hacer revisiones de arquitectura y de código con programar en pair/mob. Las primeras sesiones suelen ser muy de aterrizaje por mi parte, tienden a ser revisiones de arquitectura de alguna aplicación sobre la que vayamos a trabajar en las que busco detectar de dónde vienen los principales dolores del equipo. En esas sesiones bombardeo a preguntas para conocer qué componentes tiene y cómo interactúan entre ellos, suelo hacer mucho foco en conocer los porqués de las decisiones que se tomaron en el pasado, ya que me ayuda a entender mucho mejor la situación actual. Lo malo es que a veces ya no queda nadie en el equipo que formó parte de esas decisiones y toca tirar de hipótesis.

Respecto a las asesorías técnicas, más allá de también resevar bolsas de sesiones o de horas bastante más reducidas, no tengo un formato establecido. Cada colaboración me ha resultado una situación totalmente diferente a la otra. Las que he hecho es a gente que ha llegado a mi porque ya me conocen o alguien que se fía de mi les ha pasado mi contacto, así que empezamos con un escenario de cierta confianza inicial.

Temáticas de las colaboraciones

En las formaciones, adaptando contenidos a las necesidades de cada cliente, los temas centrales a trabajar han sido en esencia tres: Testing automático, diseño de software y DevOps, pero también me pidieron una específica de Python usando buenas prácticas. En otras de esas formaciones utilizamos Java y C#, ya que el lenguaje es parte de la adaptación.

Las formaciones relacionadas con DevOps no era algo que tuviera en mente, pero llegaron un par de peticiones de empresas interesadas, así que le propuse a Néstor hacerlo conjuntamente porque creo que lo complementamos muy bien. De ahí diseñamos una formación de tres días sobre Principios y Prácticas Devops, donde nos focalizamos en los fundamentos entrando en algunas herramientas para ilustrar algunas de las prácticas, bastante cañera en mi humilde opinión ;).

De momento se me ha quedado fuera el hacer formaciones de Specification by Example (aka BDD) y de Clean Architecture, que eran temas que me apetecían pero de momento no ha surgido la posibilidad de hacerlo. En cambio, aunque fuera sólo en formato charla, sí tuve oportuniad de hablar sobre las partes exploratoria y estratégica de Domain-Driven Design.

En cuanto a mentorías básicamente hice tres. Acompañé a un equipo en el desarrollo de una aplicación móvil desde cero, colaboré en pequeñas evoluciones de un MVP aún en la búsqueda de su market fit y ayudé en poner al día componentes que a nivel técnico se habían quedado algo estancados en un ecosistema de microservicios; en esto último aún seguimos colaborando. En estos casos fui intentando aportar en temas de testing, arquitectura, pipelines de integración y entrega continua… vamos, temas habituales. Respecto a lenguajes Kotlin (que no había tenido oportunidad de usar medianamente en serio y me ha despertado aún más curiosidad), Javascript/Node y Java respectivamente.

Dentro del cajón de las asesorías como decía he hecho cosas bastante variadas, e incluso inesperadas para mi. Algunas cosas más o menos normales; como apoyar en temas de product owning, hacer auditorías o dar feedback sobre código/arquitectura de software. Pero no esperaba hacer cosas como evaluar para contratar (o no) proveedores y servicios o hacer poco más que de patito de goma para ayudar a tomar decisiones, son cosas que no me hubiera contratado nadie sin un escenario confianza previo.

¿Y ahora qué?

Cuando arranqué el año pasado no tenía muy claro qué tal iba a funcionar, tenía dudas de si iba a encontrar un interés suficiente en el tipo de servicios que quería ofrecer para hacerlo sostenible. Pero a los meses me vi en un momento en el que tuve que pisar un poco el freno y quitarme algo de trabajo para equilibrar.

De momento hay un par de clientes con los que vengo trabajando desde hace unos meses con los que vamos a seguir haciéndolo, uno en formato mentoría y otro en asesoría.

Cuento con que prácticamente hasta el último trimestre del año no voy a arrancar ninguna nueva colaboración. Hay alguna posibilidad de colaboración a modo de asesoría que quedó pendiente de concretar tras las vacaciones de verano que debería confirmarse (o no) en cosa de un par de semanas. Y sin haber nada cerrado, quizá de cara a final del año hagamos alguna formación. Más allá de eso, una incógnita.

En fin, que como es de suponer, por mi encantado de explorar posibles nuevas colaboraciones. Así que sea para eso o para cualquier duda o tipo de cuestión vía mi email estoy disponible :). Que aunque luego no cuajen siempre me resulta muy interesante conocer equipos y contextos nuevos.

Píldora. Deshabilitar la comprobación del certificado SSL en Maven

Hace cosa de un par de semanas que he empecé a colaborar con un nuevo equipo de uno de mis clientes. En su caso trabajan en un entorno Java y utilizan Maven para construir sus proyectos. Como tienen librerías internas publicadas en un Nexus, tuve que configurar mi setting.xml para poder tener acceso a ello, hasta ahí todo normal.

Pero vez lanzando el primer mvn install me dio un error con el certificado SSL, por ser un certificado autofirmado. Así que tras buscar un poco por ahí, encontré que gracias al subproyecto Maven Wagon podemos decirle que ignore esas comprobaciones porque confiamos en el repository manager configurado:

mvn install -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -Dmaven.wagon.http.ssl.ignore.validity.dates=true