Skip to main content

Expiring records in Erlang

I'm continuing my experiments with Erlang - this time trying out gen_server with a simple key/value store with a twist - the values have an expiration date.
As a first iteration I'm simply using a dictionary to store the values, and only expiring records when they are looked up. My plan is to extend this later on so that this can be a global key/value store across multiple Erlang nodes but for now I'm focusing on two things - get something going using gen_server, and try out the common_test testing framework.
Let's first take a look at a couple of the test functions, to show the usage of this:
get_non_expired_record(Config) ->
    Pid = ?config(pid, Config),
    Record = {"bingo", "bongo", erlang:system_time(second) + 3600},
    ok = gen_server:call(Pid, {add, Record}),
    {ok, "bongo"} = gen_server:call(Pid, {fetch, "bingo"}).

get_expired_record(Config) ->
    Pid = ?config(pid, Config),
    Record = {"bingo", "bongo", erlang:system_time(second) + 1},
    ok = gen_server:call(Pid, {add, Record}),
    timer:sleep(2000),
    not_found = gen_server:call(Pid, {fetch, "bingo"}).
I should probably wrap the gen_server:call calls to make this more readable - I'm just realizing that now as I write this, but I want this blog to reflect my progress on learning Erlang, rather than just presenting some final result.
Here's the handle_call:
handle_call(Request, _From, State) ->
    D = State#state.data,
    case Request of
        {add, {Key, Value, ExpiresAt}} ->
            D2 = dict:store(Key, {Value, ExpiresAt}, D),
            {reply, ok, #state{data=D2}};

        {fetch, Key} ->
            case dict:find(Key, D) of
                {ok, {Value, ExpiresAt}} ->
                    Now = erlang:system_time(second),
                    case Now < ExpiresAt of
                        true ->
                            {reply, {ok, Value}, State};
                        _ ->
                            D2 = dict:erase(Key, D),
                            {reply, not_found, #state{data=D2}}
                    end;
                error ->
                    {reply, not_found, State}
            end;


        size ->
            {reply, dict:size(D), State};

        _ ->
            {reply, unknown_command, State}
    end.
Coming from a long background of writing in C++ and Python, the notion of having no object with a state still feels a bit weird. The gen_server process replaces that by passing the state around so it kind of boils down to the same thing. I just have to remember to return the new state when changing the dict.

Tests

I kept running into problems with eunit when trying to set up a fixture for running the various tests, all starting with a fresh instance of the expiring_records server. Looking at Common Test it seemed it might be more suitable so I've set up my tests with it this time around. I recommend this section of the Learn You Some Erlang for Great Good tutorial for getting started with Common Test.
Note that Travis CI by default runs eunit when testing Erlang projects - I had to add the following to my .travis.ymlfile:
script:
    rebar3 ct --suite app_test

What's next?

This is still very much a work in progress - I want to look at Mnesia for storing the data, rather than a simple dict. I figure that is the easiest way to achieve my goal of having this a global store across multiple nodes.
I also want to add a way to prune expired records without looking them up, to prevent the accumulation of expired records.

Popular posts from this blog

Mnesia queries

I've added search and trim to my expiring records module in Erlang. This started out as an in-memory key/value store, that I then migrated over to using Mnesia and eventually to a replicated Mnesia table. The fetch/1 function is already doing a simple query, with match_object. Result=mnesia:match_object(expiring_records, #record{key=Key, value='_', expires_at='_'}, read) The three parameters there are the name of the table - expiring_records, the matching pattern and the lock type (read lock). The fetch/1 function looks up the key as it was added to the table with store/3. If the key is a tuple, we can also do a partial match: Result=mnesia:match_object(expiring_records, #record{key= {'_', "bongo"}, value='_', expires_at='_'}, read) I've added a search/1 function the module that takes in a matching pattern and returns a list of items where the key matches the pattern. Here's the test for the search/1 function: search_partial_…

Waiting for an answer

I want to describe my first iteration of exsim, the core server for the large scale simulation I described in my last blog post. A Listener module opens a socket for listening to incoming connections. Once a connection is made, a process is spawned for handling the login and the listener continues listening for new connections. Once logged in, a Player is created, and a Solarsystem is started (if it hasn't already). The solar system also starts a PhysicsProxy, and the player starts a Ship. These are all GenServer processes. The source for this is up on GitHub: https://github.com/snorristurluson/exsim Player The player takes ownership of the TCP connection and handles communication with the game client (or bot). Incoming messages are parsed in handle_info/2 and handled by the player or routed to the ship, as appropriate. The player creates the ship in its init/1 function. The state for the player holds the ship and the name of the player. Ship The ship holds the state of the ship - …

Replicated Mnesia

I'm still working on my expiring records module in Erlang (see here and here for my previous posts on this). Previously, I had started using Mnesia, but only a RAM based table. I've now switched it over to a replicated disc based table. That was easy enough, but it took a while to figure out how to do, nonetheless. I had assumed that simply adding ... {disc_copies, [node()]} ... to the arguments to mnesia:create_table would be enough. This resulted in an error: {app_test,init_per_testcase, {{badmatch, {aborted, {bad_type,expiring_records,disc_copies,nonode@nohost}}}, ... After some head-scratching and lots of Googling I realized that I was missing a call to mnesia:create_schema to allow it to create disc based tables. My tests for this module are done with common_test so I set up a per suite initialization function like this: init_per_suite(Config) ->mnesia:create_schema([node()]), mnesia:start(…