Here we'll show how to implement a simple yet illustrative cluster database for managing accounts in a bank.
Features:
- Database:
- Adding a new user with accounts.
- Reading information about a user and her accounts.
- Updating accounts.
- Ability to work with the cluster like with a RESTful server via the http protocol.
- Tests based on luatest.
Before you start developing with cartridge
, you need to install several utilities:
git
— version control system (see details here)cmake
version 2.8 or highertarantool-devel
— developer package fortarantool
gcc
—C
compiler (see details here)unzip
To create your project in a quick and easy manner, install tarantool
and cartridge-cli
tool:
-
For CentOS, Fedora, or ALT Linux (RPM package):
curl -L https://tarantool.io/release/2/installer.sh | bash sudo yum -y install tarantool sudo yum install cartridge-cli
-
For Debian or Ubuntu (DEB package):
curl -L https://tarantool.io/release/2/installer.sh | bash sudo apt-get -y install tarantool sudo apt-get install cartridge-cli
-
For macOS (Homebrew formula):
brew install tarantool brew install cartridge-cli
Now you are ready to go!
We don't need to create the project from grounds up — just use a template.
For now we have single template: cartridge
.
In this example we'll be using the cartridge
template because further we'll need
to shard our solution.
Let's create a project from the template. Our project name will be
getting-started-app
(you can choose any name you like).
you@yourmachine $ cartridge create --name getting-started-app
In the end of the output, we'll see the following message:
Application successfully created in './getting-started-app'
Now let's take a closer look at the structure of the created project:
you@yourmachine $ cd getting-started-app
getting-started-app $ find . -not -path '*/\.*'
.
./init.lua
./app
./app/roles
./app/roles/custom.lua
./test
./test/unit
./test/unit/sample_test.lua
./test/integration
./test/integration/api_test.lua
./test/helper.lua
./test/helper
./test/helper/unit.lua
./test/helper/integration.lua
./cartridge.post-build
./Dockerfile.build.cartridge
./getting-started-app-scm-1.rockspec
./instances.yml
./deps.sh
./cartridge.pre-build
./tmp
./Dockerfile.cartridge
In fact, there are many more objects in the template — at least because we
initialize a git
repository with a .gitignore
file. For now let's focus only
on the following files and directories:
deps.sh
— a one-liner to easily install dev dependencies (luatest
,luacheck
,luacov
etc.).init.lua
— an entry point for our application. In ths example, we won't go deep into the details of this script, but to develop a more complex system you'll need to be aware of what this script is doing.app/roles
— our main work directory. Here we'll define all our roles.getting-started-app-scm-1.rockspec
— a "rockspec", i.e. a manifest file of our project. In this tutorial, we discuss only a small part of it which deals with project dependencies.
Already at this stage, right after created from the template, our project is nearly ready to launch. All we need to do is pull dependencies. We can do it by running this in the project root:
getting-started-app $ cartridge build
All the modules we need must get pulled to the .rocks
directory.
If we fail to pull dependencies, the project will fail to launch, and we'll see a message like this:
getting-started-app $ tarantool init.lua
init.lua:62: module 'cartridge' not found:No LuaRocks module found for cartridge
no field package.preload['cartridge']
no file 'getting-started-app/cartridge.lua'
no file 'getting-started-app/cartridge/init.lua'
no file 'getting-started-app/cartridge.dylib'
no file 'getting-started-app/cartridge.so'
...
Let's install dev dependencies (e.g luatest
, luacheck
, luacov
) right away:
getting-started-app $ ./deps.sh
If we pulled dependencies in a proper way and the .rocks
directory was created,
we can launch an "empty" instance:
getting-started-app $ tarantool init.lua
Using advertise_uri "localhost:3301"
Membership encryption enabled
Membership BROADCAST sent to 127.0.0.1:3302
Membership BROADCAST sent to 192.168.43.255:3302
Membership BROADCAST sent to 127.0.0.1:3301
Membership BROADCAST sent to 192.168.43.255:3301
Membership BROADCAST sent to 127.0.0.1:3300
Membership BROADCAST sent to 192.168.43.255:3300
started
Listening HTTP on 0.0.0.0:8081
Ready for bootstrap
entering the event loop
Mind this line:
Listening HTTP on 0.0.0.0:8081
This means that the http server is launched and listens on port 8081 of our computer. We can navigate to http://127.0.0.1:8081 in a browser and see the cluster's web interface, which shows that our cluster consists a single Tarantool instance.
Now let's stop the application with Ctrl+C and start writing the code.
Role is part of our application, which is logically different from the other parts.
To implement a role that will be working in the cluster, you need — apart from coding the business logic for this role — to code several callback functions that the cluster will be using to manage the role's life cycle.
There are just a few functions, and nearly all of them were implemented as stubs
when we created our project from the template. Here is what we can find in
app/roles/custom.lua
:
init(opts)
— creating and initializing the role.stop()
— stopping the role;validate_config(conf_new, conf_old)
— validating new configuration for the role;apply_config(conf, opts)
— applying new configuration.
The role file itself is a Lua module, with a list of functions and variables that the modules exports:
return {
role_name = 'custom_role',
init = init,
stop = stop,
validate_config = validate_config,
apply_config = apply_config,
dependencies = {},
}
Let's split our application into two roles:
- The
storage
role implements storing and changing information about customers and accounts. - The
api
role implements a RESTful http server.
Let's create a new file where we'll be implementing this role:
getting-started-app $ touch app/roles/storage.lua
-
Require the necessary modules:
-- module for checking arguments in functions local checks = require('checks') -- module for working with numbers local decnumber = require('ldecnumber')
-
Implement space initialization in the storage:
local function init_spaces() local customer = box.schema.space.create( -- name of the space for storing customers 'customer', -- extra parameters { -- format for stored tuples format = { {'customer_id', 'unsigned'}, {'bucket_id', 'unsigned'}, {'name', 'string'}, }, -- creating the space only if it doesn't exist if_not_exists = true, } ) -- creating an index by customer's id customer:create_index('customer_id', { parts = {'customer_id'}, if_not_exists = true, }) customer:create_index('bucket_id', { parts = {'bucket_id'}, unique = false, if_not_exists = true, }) -- similarly, creating a space for accounts local account = box.schema.space.create('account', { format = { {'account_id', 'unsigned'}, {'customer_id', 'unsigned'}, {'bucket_id', 'unsigned'}, {'balance', 'string'}, {'name', 'string'}, }, if_not_exists = true, }) -- similar indexes account:create_index('account_id', { parts = {'account_id'}, if_not_exists = true, }) account:create_index('customer_id', { parts = {'customer_id'}, unique = false, if_not_exists = true, }) account:create_index('bucket_id', { parts = {'bucket_id'}, unique = false, if_not_exists = true, }) end
-
Implement a function to add a new customer:
local function customer_add(customer) customer.accounts = customer.accounts or {} -- opening a transaction box.begin() -- inserting a tuple into the customer space box.space.customer:insert({ customer.customer_id, customer.bucket_id, customer.name }) for _, account in ipairs(customer.accounts) do -- inserting a tuple into the account space box.space.account:insert({ account.account_id, customer.customer_id, customer.bucket_id, '0.00', account.name }) end -- committing the transaction box.commit() return true end
-
Implement a function for balance update:
local function update_balance(balance, amount) -- Converts string to decimal object. local balance_decimal = decnumber.tonumber(balance) balance_decimal = balance_decimal + amount if balance_decimal:isnan() then error('Invalid amount') end -- Rounds up to 2 decimal places and converts back to string. return balance_decimal:rescale(-2):tostring() end
-
Implement a function for account update:
local function customer_update_balance(customer_id, account_id, amount) -- checking function arguments checks('number', 'number', 'string') -- finding the required account in the database local account = box.space.account:get(account_id) if account == nil then -- checking if the account was found return nil end -- Checks account's validity. if account.customer_id ~= customer_id then error('Invalid account_id') end local new_balance = update_balance(account.balance, amount) -- updating the balance box.space.account:update({ account_id }, { { '=', 4, new_balance } }) return new_balance end
-
Implement a function to receive information about a customer:
local function customer_lookup(customer_id) checks('number') local customer = box.space.customer:get(customer_id) if customer == nil then return nil end customer = { customer_id = customer.customer_id; name = customer.name; } local accounts = {} for _, account in box.space.account.index.customer_id:pairs(customer_id) do table.insert(accounts, { account_id = account.account_id; name = account.name; balance = account.balance; }) end customer.accounts = accounts; return customer end
-
Implement a function to initialize the
storage
role:local exported_functions = { customer_add = customer_add, customer_lookup = customer_lookup, customer_update_balance = customer_update_balance, } local function init(opts) if opts.is_master then -- calling the space initialization function init_spaces() for name in pairs(exported_functions) do box.schema.func.create(name, {if_not_exists = true}) box.schema.role.grant('public', 'execute', 'function', name, {if_not_exists = true}) end end for name, func in pairs(exported_functions) do rawset(_G, name, func) end return true end
-
Exporting role functions and dependencies from the module:
return { role_name = 'storage', init = init, -- for test purposes utils = { update_balance = update_balance }, dependencies = { 'cartridge.roles.vshard-storage', }, }
Our first role is implemented!
-
Require the necessary modules:
local vshard = require('vshard') local cartridge = require('cartridge') local errors = require('errors')
-
Create error's classes:
local err_vshard_router = errors.new_class("Vshard routing error") local err_httpd = errors.new_class("httpd error")
-
Implement a handler for add-a-customer http request:
local function http_customer_add(req) local customer = req:json() local bucket_id = vshard.router.bucket_id(customer.customer_id) customer.bucket_id = bucket_id local _, error = err_vshard_router:pcall( vshard.router.call, bucket_id, 'write', 'customer_add', {customer} ) if error then local resp = req:render({json = { info = "Internal error", error = error }}) resp.status = 500 return resp end local resp = req:render({json = { info = "Successfully created" }}) resp.status = 201 return resp end
-
Implement a handler for get-customer-info http request:
local function http_customer_get(req) local customer_id = tonumber(req:stash('customer_id')) local bucket_id = vshard.router.bucket_id(customer_id) local customer, error = err_vshard_router:pcall( vshard.router.call, bucket_id, 'read', 'customer_lookup', {customer_id} ) if error then local resp = req:render({json = { info = "Internal error", error = error }}) resp.status = 500 return resp end if customer == nil then local resp = req:render({json = { info = "Customer not found" }}) resp.status = 404 return resp end customer.bucket_id = nil local resp = req:render({json = customer}) resp.status = 200 return resp end
-
Implement a handler for update-customer-account http request:
local function http_customer_update_balance(req) local customer_id = tonumber(req:stash('customer_id')) local body = req:json() local account_id = tonumber(body["account_id"]) local amount = body["amount"] local bucket_id = vshard.router.bucket_id(customer_id) local balance, error = err_vshard_router:pcall( vshard.router.call, bucket_id, 'read', 'customer_update_balance', {customer_id, account_id, amount} ) if error then local resp = req:render({json = { info = "Internal error", error = error }}) resp.status = 500 return resp end if balance == nil then local resp = req:render({json = { info = "Account not found" }}) resp.status = 404 return resp end local resp = req:render({json = {balance = balance}}) resp.status = 200 return resp end
-
Initialize the role:
local function init(opts) rawset(_G, 'vshard', vshard) if opts.is_master then box.schema.user.grant('guest', 'read,write,execute', 'universe', nil, { if_not_exists = true } ) end local httpd = cartridge.service_get('httpd') if not httpd then return nil, err_httpd:new("not found") end -- assigning handler functions httpd:route( { path = '/storage/customers/create', method = 'POST', public = true }, http_customer_add ) httpd:route( { path = '/storage/customers/:customer_id', method = 'GET', public = true }, http_customer_get ) httpd:route( { path = '/storage/customers/:customer_id/update_balance', method = 'POST', public = true }, http_customer_update_balance ) return true end
-
Export the role's functions and dependencies from the module:
return { role_name = 'api', init = init, dependencies = {'cartridge.roles.vshard-router'}, }
Copy instances.yml
from the example repository to the root of your project repository,
it is an example of cluster configuration file (5 instances);
We are almost there. We only need to do two things. First, list the new roles
in the init.lua
file that you'll find in the project root:
local cartridge = require('cartridge')
local ok, err = cartridge.cfg({
roles = {
'cartridge.roles.vshard-storage',
'cartridge.roles.vshard-router',
'cartridge.roles.metrics',
'app.roles.api',
'app.roles.storage',
},
cluster_cookie = 'getting-started-app-cluster-cookie',
})
Second, remember that we used some external modules for our roles, and add these
modules to the list of dependencies in the rockspec file
getting-started-app-scm-1.rockspec
:
package = 'getting-started-app'
version = 'scm-1'
source = {
url = '/dev/null',
}
-- Put any modules your app depends on here
dependencies = {
'tarantool',
'lua >= 5.1',
'checks == 3.3.0-1',
'cartridge == 2.8.4-1',
'ldecnumber == 1.1.3-1',
'metrics == 1.0.0-1',
'cartridge-metrics-role == 0.1.1-1',
}
build = {
type = 'none';
}
We are ready to launch the cluster now!
getting-started-app $ cartridge build
getting-started-app $ cartridge start -d
Open the web interface and perform the following actions:
- Create one instance with the
'api'
role.
- Create another instance with the
'storage'
role.
As a result, we'll get two replica sets, with one Tarantool instance in each.
Now we have two replica sets implementing their roles, but vshard
is not running yet.
Press the button Bootstrap vshard
on Cluster
tab.
Also, you should enable failover for stable work. Open Failover control
tab, choose Stateful failover
and click Apply
.
You can also use cartridge-cli
to setup and run all instances and Stateful failover:
cartridge replicasets setup --bootstrap-vshard
cartridge failover set stateful --state-provider stateboard --provider-params '{"uri": "localhost:4401", "password": "passwd"}'
Open a new console and add a customer using curl
:
getting-started-app $ curl -X POST -v -H "Content-Type: application/json" -d '{"customer_id":18, "name": "Victor"}' http://localhost:8081/storage/customers/create
The output will look like this:
* Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> POST /storage/customers/create HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 51
>
* upload completely sent off: 51 out of 51 bytes
< HTTP/1.1 201 Created
< Content-length: 31
< Server: Tarantool http (tarantool v1.10.3-94-g98964ba8d)
< Content-type: application/json; charset=utf-8
< Connection: keep-alive
<
* Connection #0 to host localhost left intact
{"info":"Successfully created"}
Perfect! Now let's get down to tests, but first we'll need to stop the cluster with Ctrl+C.
You'll find already implemented tests in the repository.
These tests are based on luatest.
To copy our tests to your project, just replace the existing
test
directory in your project with the
test
directory in the example repository.
If you haven't installed dev
dependencies, please do that:
getting-started-app $ ./deps.sh
Writing tests is a topic for another tutorial. Here we'll just run the tests that were already implemented for this example:
getting-started-app $ .rocks/bin/luatest
The output will look like this:
..
Ran 2 tests in 0.021 seconds, 2 successes, 0 failures
OK
You can also use luacov
to check the code coverage of the tests.
Run luatest
with the --coverage
option:
getting-started-app $ .rocks/bin/luatest --coverage
Generate a report:
getting-started-app $ .rocks/bin/luacov
Show a summary:
getting-started-app $ grep -A999 '^Summary' tmp/luacov.report.out
Do not forget to clean up the previous reports before generating new ones:
getting-started-app $ rm -f tmp/luacov.*.out*