Hi, Unbabel recruiting team!
This is my solution to the Unbabel Backend Challenge.
I've built a Flask web application which translates input text from English to Spanish using Unbabel's Translation API and presents it in a list of translations dynamically updated with 3 different statuses: requested, pending and translated.
I've chosen a domain-driven architecture with event-sourcing for handling and persisting the translation status changes. All network tasks are running in the background and a cache is used for enqueing these messages. There are tests implemented for the domain layer - covering the translation services, event-sourcing model and repositories.
The application builds in a Docker container and runs with docker-compose.
Documentation consists on this README.md and source code documentation (docstrings).
- Docker
- docker-compose
- ngrok (optional for HTTP tunneling and receiving callbacks)
-
Start
ngrok
on HTTP port 5000:./ngrok http 5000
-
Copy the
.env.template
to a.env
file and set the required environment variables. -
Run the application:
docker-compose up -f docker-compose.yml --build --force-recreate
-
Access it:
Here I try to break the steps I took to develop the solution - which is described later in this document.
My references are linked here alongside with the roadmap topics - some I've already implemented for other projects and some I've had to study for this challenge.
- Fork the project. [OK]
- Implement a simple Unbabel API client. [OK]
- Test the Unbabel's API client. [OK]
- Implement the Event Sourcing model (following this blog post). [OK]
- Test the Event Sourcing model with Hypothesis. [OK]
- Implement the Event Sourcing persistence (see this blog post). [OK]
- Test the Event Sourcing persistence. [OK]
- Study a Flask web application (Flask Mega-tutorial). [OK]
- Implement a simple Flask web application. [OK]
- Configure a simple docker-compose. [OK]
- Implement a Dramatiq worker. [OK]
- Study a production server (see gunicorn). [OK]
- Improve the docker-compose. [OK]
- Implement the application frontend. [OK]
Implement an event queue for the frontend (try Flask-SSE).- Implement dynamic updates for the frontend with AJAX polling. [OK]
Study how to test Flask applications.Test the Flask application.- Implement a callback. [OK]
- Improve error handling. [OK]
- Improve environment variables and configuration. [OK]
Test application performance with JMeter or Locust.- Write docstrings. [OK]
Generate HTML documentation with Sphinx.- Write a README. [OK]
- Organize commits with
git rebase
and clean feature branches. [OK] - Deliver it. [OK]
I've designed the application in a simple domain-driven approach trying to keep the Flask application (application layer), the translations logic (domain layer) and the data operations (persistence layer) isolated. The following diagram summarizes the architecture described in the next sessions.
My Flask reference is Miguel Grinberg's Flask Mega-Tutorial book which I have initially followed but as it uses available Flask extensions for everything, it makes too difficult to decouple the application layers.
For settings management I am using dynaconf
and its Flask extension. For the background tasks, I let Dramatiq
handle the queues using Redis
cache just because it's simple to configure - but I could have used RabbitMQ as well.
For the frontend, I am using Flask-Bootstrap
styles, Flask-WTF
forms and AJAX to poll the translations list.
An attempt of using Flask-SSE
to notify all clients with changes instead of AJAX polling failed - it couldn't connect to the same Redis
cache as Dramatiq
. Configuring 2 Redis
instances with docker-compose
was taking me too much time, an alternative would be using RabbitMQ for the tasks and Redis for the frontend cache instead.
For the translation services, I've chosen an event-sourcing approach (based on this series of blog posts) to properly handle the translation service status changes. My interpretation of the problem suggests the following logic:
- requested: As soon as an input text is submitted to our application via the HTML forms, it is considered a requested translation.
- pending: After sent to a translation service (in our case it's Unbabel API), its status changes from requested to pending and we have to wait until its completion (as we are requesting manual translations).
- finished: When the translation service completes processing the input text (translating it from English to Spanish) and sends a POST to a callback URL in the application, our translation status changes to finished.
All status changes are recorded as events in an event aggregate (which is a Translation
object). This way, instead of having only the final state of an object, we can track down every modification (e.g. timestamps) and add other events to the logic in the future (other translation services appending to the events stream - internal x external services, automatic x manual services, etc).
To query and visualize this information, though, I've implemented a simple read-model of the event-sourced aggregate containing the current state of every translation and required information as the string length for ordering the translations list.
The persistence layer is implemented with SQLAlchemy
and a PostgreSQL database.
Note: _Currently, we have a PostgreSQL container running within the same
docker-compose
the application runs. This is definetely not the best approach - the schema and tables are recreated everytime (or persisted in an external volume).This setup could be easily changed in the
.env
file assigning to a proper hosted PostgreSQL database to the database URI environment variable instead.
The write-model for event-sourcing is implemented with 2 tables. One for Aggregates and another for Events. The read-model for visualization (which can be generated from the write-model on demand) is a single Translations table. Other projections could be added (e.g. read-models for management, auditing or analytics).
The following schema represents the implemented challenge schema.
Note: The
Version
column in the Aggregates table is used as an optimistic lock - if 2 transactions attempt to update the same resource, the first one will complete and increment this value. Then, the second will try to update a version that doesn't exist anymore and fail. It's a simple mechanism of robustness
The following sequence diagram is a simple "good path"-only diagram illustrating the most important components calls and behaviors. It doesn't cover all paths (e.g. ignoring error handling) and it doesn't include the read-model (projections) when updating clients. Although, it's a good "mind map" of what is happening when a new translation is requested in a browser and sent to our application.
This project follows a Karma git-sh style for commit messages.
The single production, staging and development branches is avoided and feature branches were used instead.
As soon as feature branches are merged into master, they were removed. Unfortunately, no PRs and code reviews were opened during development. (As I was developing all by myself, I've forgotten how useful it could be.)
"Baby steps" commits are recommended, but unfortunately (Shame on me!) a bunch of them are huge and contain too much changes to be reviewed in a single pass. Usually, it gets better after a large project is already up and running.
Note: I'll try to re-organize commits with
git rebase
and improve the challenge reviewers experience.
The root directory contains all configuration files required:
.gitignore
and.dockerignore
;requirements.txt
with required Python packages;.env.template
with environment variables which must be set before running;docker-compose.yml
files andDockerfile
for building and deploying the application;docker-compose.tests.yml
andDockerfile
files for building and testing the application.
And documentation:
- This
README.md
file and itsresources
.
The application is developed as a challenge
package and, as described above, it isolates the web application from the application domain and data persistence layers. The following image illustrates it with more details.
For development, I've used virtuallenvwrapper
and Visual Studio Code. All Python files were linted with Flake8
(which is faster), pylint
(which covers more issues), pydocstrings
, formatted with yapf
and isort
.
As time ran short and I still need to learn how to properly test a web application from end-to-end, I've decided to test the core of this project, focusing on the happy path. There are no tests implemented for the application layer or persistence layer, but the essential classes of the domain layer were tested:
- The Unbabel's API client;
- The Translator service;
- The event-sourcing model (Aggregates, Events and Translations);
- The event-source aggregates Repository class.
Not all tests are isolated unit tests - this way testing the repository also stresses PostgreSQL queries from the Persistence layer, for instance. Most of these tests were implemented with Hypothesis
Python package, which does a good job automatically generating tests cases (and corner cases).
The frontend tests are excluded as the application routes, templates and read-model. Studying Selenium
could apply here.
A simple browser debugger or extension is capable of returning the average load time of the application /index
page - which is around 150 ms. A better approach would be test it with Locust
or JMeter
and do a proper analysis - scaling worker processes and threads as necessary.
There are docker-compose
files for running both the application and tests with their dependencies (Redis and PostgreSQL). To do so, follow these steps:
- Start an HTTP tunnel to the local port 5000 - the Unbabel's API will need a callback URL. I'm using ngrok, but there are other solutions available.
./ngrok http 5000
- Edit the
.env.template
file with the required environment variables (see the following snippet). Save it as.env
.
###############################################################################
# CONFIGURE THE FOLLOWING ENVIRONMENT VARIABLES BEFORE RUNNING!
###############################################################################
# API client
UNBABEL_API_CLIENT=
# API token
UNBABEL_API_TOKEN=
# API callback URL. Please, consider the following:
# - The format MUST be 'http[s]://<host>/callback/' (with quotes).
# - The host might be a Ngrok HTTP tunnel to your local port 5000.
# - The endpoint MUST be "callback" as it's implemented this way.
# - The last slash ("/") MUST be explicit
UNBABEL_API_CALLBACK=
###############################################################################
# DO NOT EDIT ANYTHING FROM NOW ON
###############################################################################
...
- Run tests.
docker-compose up -f docker-compose.tests.yml --build --force-recreate
After finishing, you will need to stop the docker-container by yourself (CTRL + C
will do).
challenge_app | =================== test session starts ====================
challenge_app | platform linux -- Python 3.6.7, pytest-4.1.0, py-1.7.0, pluggy-0.8.0
challenge_app | hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase('/src/.hypothesis/examples')
challenge_app | rootdir: /src, inifile:
challenge_app | plugins: hypothesis-3.86.5
challenge_app | collected 28 items
challenge_app |
challenge_app | tests/domain/test_repositories.py .. [ 7%]
challenge_app | tests/domain/model/test_base.py ..... [ 25%]
challenge_app | tests/domain/model/test_translation.py ........... [ 64%]
challenge_app | tests/domain/services/test_translator.py ... [ 75%]
challenge_app | tests/domain/services/unbabel/test_client.py ....... [100%]
challenge_app |
challenge_app | ===================== warnings summary =====================
...
challenge_app | ========= 28 passed, 4 warnings in 111.67 seconds ==========
challenge_app exited with code 0
- Run the application.
docker-compose up -f docker-compose.yml --build --force-recreate
- Access the application using the HTTP tunnel or http://localhost:5000/
The following issues are known and yet to be fixed:
Redis
is used as a message queue for the background workers without too much configuration. It's not reliable even thoughDramatiq
is managing it. A better solution would required properly configuring a message queue system asRabbitMQ
and adding it to thedocker-compose
files.- After some time, the AJAX polling loses connection with the PostgreSQL database. It requires further investigation, but implementing the event server with
Flash-SSE
could do. - After the translations list is dynamically updated, the "Newer translations" and "Older translations" buttons are not. It's required to refresh the page after 10 entries to do so.
- Escape characters are returned from the PostgreSQL, sent to the translation services and printed in the frontend as well. Some translators deal with it, others don't.
- There are no control over requests (to the databases or APIs).
Tenacity
could be of help. - There are insufficient tests for the application and persistence layer.
- There are no stress tests to say the application is scalable.