You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by ei...@apache.org on 2020/05/07 13:24:35 UTC

[couchdb] branch aegis_expiring_cache updated (d529bc5 -> 8e91e63)

This is an automated email from the ASF dual-hosted git repository.

eiri pushed a change to branch aegis_expiring_cache
in repository https://gitbox.apache.org/repos/asf/couchdb.git.


 discard d529bc5  Convert key cache to be LRU
    omit 2b39a34  Fix aegis_server multidb test, allow waiters to properly accumulate
    omit 2136bb0  Switch to db UUID for cache's key
    omit 9413100  Remove example key manager, add noop key manager, make it default
    omit 2ec4bc8  Change key manager response on disabled encryption to `false`.
    omit 1f05a44  Make example key manager use pbkdf2 to derive root key
    omit d17604b  Convert aegis_key_manager into gen server
    omit c971c3e  Store unwrappers Ref as a control for DbKey legitimacy
    omit 25022fd  Support disabling encryption per database on key manager level
    omit 07178a9  Pass db options to generate_key and rename WrappedKey to AegisConfig
    omit 6069181  Change rebar deps build order to allow aegis find fabric2.hrl header file
    omit 0880fdc  Address review comments
    omit 061b6b7  Refactoring: extract maybe_spawn_unwrapper into own function
    omit b384ddf  Rename clients to openers
    omit 0b96aba  Rename unwrap_key to maybe_rewrap_key to clarify fun propose
    omit a3574fe  Make encrypt/decrypt workers to reply client directly
    omit 7b78636  Formatting: move private functions into own section
    omit f8ac2b2  Extract aegis_keywrap parts in shim of key manager
    omit 80d75e8  Return error if can't unwrap a key
    omit a76694c  Fail tests on gen_server timeout
    omit 9916ac2  Make fabric depend on aegis
    omit 5ad9226  Make aegis into app and add key cache server
    omit a8c9d8e  Move rebar.config.script from couch to aegis
    omit 14b1e25  Fix typo in configure
    omit 34c5b85  Add encryption for database values
     add 2ba98a8  Return better responses for endpoints which are not implemented
     add 1be2363  Fix POST _all_docs/queries endpoint
     add e71a77d  Do not allow editing _security in _user database
     add 1d6799f  Start running chttpd eunit tests
     add 0d1cf61  Support soft-deletion in fabric level
     add ec12e1f  Support soft-deletion in chttpd level
     add 6c1d7a9  Merge pull request #2666 from apache/db-softdeletion
     add d6ec993  Compress doc bodies and attachments
     add a14f62d  Add mango_plugin
     add 396a3b5  Merge pull request #2767 from cloudant/prototype/fdb-layer-mango-plugin
     add 56137f3  Fix job removal notifications
     add daf1082  Fix division by zero
     add cbad08d  Make 'make check' run all the passing FDB tests on this branch
     add 2bb0ccd  Fix incorrect usage of couch_epi in mango plugin
     add aad871b  Merge pull request #2775 from cloudant/mango-plugin-fixup
     add d4bc3a5  Fix flaky fabric2_index test
     add 742c64e  Fix index updater configuration keys
     add 247b809  Rename variables to indicate transaction state
     add 3c0a017  Move process_db/1 to match the logical progression
     add 3e1c822  Update to use `fabric2_db:get_design_docs/1`
     add 7bc9148  Extend fabric2_index callbacks for index cleanup
     add e0d0391  Implement couch_views:cleanup_indices/2
     add 4275a49  Implement _view_cleanup for FoundationDB
     add 7aeb54b  Optionally cleanup stale indices automatically
     add 2e5a556  Remove jobs on index cleanup
     add 30fdef7  Remove failed view jobs
     add 7575152  Implement couch_views_cleanup_test.erl
     add 9da549e  Integrate emilio - erang linter
     add 6bc6f9c  Merge pull request #2789 from cloudant/fdb-integrate-emilio
     add 3636451  Enable configurable binary chunk size
     add 27cbad7  report changes stats intermittently (#2777)
     add 0eb1a73  Refactor expiring cache FDB interface
     add 4098c12  Clean up old expiry key on update insert
     add 4e2f18c  Merge keys from rebar.config
     add a527ad1  Merge pull request #2783 from cloudant/merge-rebar-config
     add f71c4c0  Allow using cached security and revs_limit properties
     add 0b8dfa6  Fetch doc in same transaction as _all_doc row
     add 45e0c30  Fix typo in error message
     add 8554329  Merge pull request #2796 from cloudant/fix-typo
     add 3895223  Add after_interactive_write plugin to couch_views_updater
     add f522b88  Refactor fetching rev code in fabric2_fdb
     add 5efcbfc  Add fold_docs for DocId list
     add 232e1d5  Report the chttpd_auth authentication db in session info
     add a5fded8  Add native encryption support
     add 43da896  Merge pull request #2826 from apache/aegis
     add 21bb444  Add a couch_views test for multiple design documents with the same map
     add 0a74954  Update erlfdb to v1.1.0
     add b07a629  Allow specifying FDB transaction options
     add e889cf0  Fix mango test suite
     add 19d8582  Remove etag from changes and _list_dbs
     add c6d3a7b  Temporary disable fabric2_tx_options_tests
     add cf0b032  Re-enable the tx options tests
     add 97227c4  Improve robustness of couch expiring cache test
     add e48da92  Fix a flaky fdbcore index test
     add 45a899a  Fix list_dbs_info_tx_too_old flaky test
     add bd44fc6  return correct not implemented for reduce
     add 7e7a3f6  add test to make sure type <<"text">> design docs are ignored (#2866)
     add 577be65  Re-enable ExUnit tests
     add 51b8cc1  Update erlfdb
     add 25bf5cb  Merge pull request #2874 from cloudant/enable-exunit
     new 8e91e63  Convert aegis key cach to LRU with hard expiration time

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (d529bc5)
            \
             N -- N -- N   refs/heads/aegis_expiring_cache (8e91e63)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .gitignore                                         |   2 +
 Makefile                                           |  19 +-
 Makefile.win                                       |   7 +-
 bin/warnings_in_scope                              | 125 ++++++
 build-aux/Jenkinsfile.pr                           |   2 +-
 configure                                          |  13 +
 configure.ps1                                      |  14 +
 emilio.config                                      |  20 +
 rebar.config.script                                |  11 +-
 rel/overlay/etc/default.ini                        |  68 ++-
 src/aegis/src/aegis.erl                            |  42 +-
 src/aegis/src/aegis_key_manager.erl                |  99 +----
 src/aegis/src/aegis_noop_key_manager.erl           |  15 +-
 src/aegis/src/aegis_server.erl                     | 463 ++++++++++-----------
 src/aegis/src/aegis_sup.erl                        |   5 -
 src/aegis/test/aegis_key_manager_test.erl          |  84 ----
 src/aegis/test/aegis_server_test.erl               | 305 +++++++-------
 src/chttpd/src/chttpd.erl                          |  15 +-
 src/chttpd/src/chttpd_db.erl                       |  90 ++--
 src/chttpd/src/chttpd_httpd_handlers.erl           |  40 +-
 src/chttpd/src/chttpd_misc.erl                     | 136 ++++--
 src/chttpd/src/chttpd_stats.erl                    |  96 ++++-
 src/chttpd/src/chttpd_test_util.erl                |   2 +-
 src/chttpd/src/chttpd_view.erl                     |  20 +-
 .../eunit/chttpd_db_bulk_get_multipart_test.erl    |  31 +-
 src/chttpd/test/eunit/chttpd_db_bulk_get_test.erl  |  30 +-
 src/chttpd/test/eunit/chttpd_db_test.erl           |  38 +-
 src/chttpd/test/eunit/chttpd_dbs_info_test.erl     |  13 +-
 src/chttpd/test/eunit/chttpd_deleted_dbs_test.erl  | 234 +++++++++++
 .../test/eunit/chttpd_open_revs_error_test.erl     | 112 -----
 src/chttpd/test/eunit/chttpd_purge_tests.erl       |   6 +-
 src/chttpd/test/eunit/chttpd_security_tests.erl    |  57 +--
 src/chttpd/test/eunit/chttpd_session_tests.erl     |  74 ++++
 src/chttpd/test/eunit/chttpd_stats_tests.erl       |  77 ++++
 .../test/eunit/chttpd_test.hrl}                    |   2 +
 src/chttpd/test/eunit/chttpd_view_test.erl         |   4 +-
 src/couch/.gitignore                               |   2 +
 src/couch/rebar.config.script                      |   7 +-
 src/couch/src/couch_db.erl                         |   2 +
 src/couch/src/couch_httpd_auth.erl                 |   3 +-
 .../src/couch_expiring_cache_fdb.erl               |  87 ++--
 .../src/couch_expiring_cache_server.erl            |  12 +-
 .../test/couch_expiring_cache_tests.erl            | 106 +++--
 src/couch_jobs/src/couch_jobs_fdb.erl              |   4 +
 src/couch_jobs/test/couch_jobs_tests.erl           |  28 ++
 src/couch_rate/src/couch_rate_limiter.erl          |   2 +
 src/couch_views/src/couch_views.erl                |  21 +-
 src/couch_views/src/couch_views_epi.erl            |   4 +-
 src/couch_views/src/couch_views_fdb.erl            |  40 +-
 src/couch_views/src/couch_views_indexer.erl        |   2 +-
 src/couch_views/src/couch_views_jobs.erl           |  35 +-
 src/couch_views/src/couch_views_plugin.erl         |  40 ++
 src/couch_views/src/couch_views_updater.erl        |   5 +-
 src/couch_views/test/couch_views_cleanup_test.erl  | 411 ++++++++++++++++++
 src/couch_views/test/couch_views_indexer_test.erl  |  90 +++-
 src/couch_views/test/couch_views_map_test.erl      |   2 +-
 src/fabric/include/fabric2.hrl                     |   9 +-
 src/fabric/src/fabric2_db.erl                      | 332 ++++++++++++++-
 src/fabric/src/fabric2_fdb.erl                     | 237 +++++++++--
 src/fabric/src/fabric2_index.erl                   |  73 ++--
 src/fabric/src/fabric2_server.erl                  | 109 ++++-
 src/fabric/src/fabric2_util.erl                    |  24 ++
 src/fabric/test/fabric2_db_crud_tests.erl          | 235 ++++++++++-
 src/fabric/test/fabric2_db_misc_tests.erl          |  34 +-
 src/fabric/test/fabric2_doc_att_tests.erl          |  52 ++-
 src/fabric/test/fabric2_index_tests.erl            |  10 +-
 src/fabric/test/fabric2_tx_options_tests.erl       | 103 +++++
 src/mango/src/mango_epi.erl                        |   4 +-
 src/mango/src/mango_httpd.erl                      |  20 +-
 src/mango/src/mango_idx.erl                        |   3 +-
 .../src/mango_plugin.erl}                          |  40 +-
 src/mango/test/16-index-selectors-test.py          |   8 +
 test/elixir/test/all_docs_test.exs                 |  30 +-
 73 files changed, 3394 insertions(+), 1203 deletions(-)
 create mode 100755 bin/warnings_in_scope
 create mode 100644 emilio.config
 delete mode 100644 src/aegis/test/aegis_key_manager_test.erl
 create mode 100644 src/chttpd/test/eunit/chttpd_deleted_dbs_test.erl
 delete mode 100644 src/chttpd/test/eunit/chttpd_open_revs_error_test.erl
 create mode 100644 src/chttpd/test/eunit/chttpd_session_tests.erl
 create mode 100644 src/chttpd/test/eunit/chttpd_stats_tests.erl
 copy src/{fabric/test/fabric2_test.hrl => chttpd/test/eunit/chttpd_test.hrl} (97%)
 create mode 100644 src/couch_views/src/couch_views_plugin.erl
 create mode 100644 src/couch_views/test/couch_views_cleanup_test.erl
 create mode 100644 src/fabric/test/fabric2_tx_options_tests.erl
 copy src/{global_changes/src/global_changes_plugin.erl => mango/src/mango_plugin.erl} (59%)


[couchdb] 01/01: Convert aegis key cach to LRU with hard expiration time

Posted by ei...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

eiri pushed a commit to branch aegis_expiring_cache
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 8e91e639b4a73b4f48828f1616d978307695dc9d
Author: Eric Avdey <ei...@eiri.ca>
AuthorDate: Tue May 5 16:38:57 2020 -0300

    Convert aegis key cach to LRU with hard expiration time
---
 src/aegis/src/aegis_server.erl       | 178 ++++++++++++++++++++++++++++++-----
 src/aegis/test/aegis_server_test.erl | 149 +++++++++++++++++++++++++++++
 src/fabric/src/fabric2_util.erl      |   8 ++
 3 files changed, 309 insertions(+), 26 deletions(-)

diff --git a/src/aegis/src/aegis_server.erl b/src/aegis/src/aegis_server.erl
index be8202c..ed576c5 100644
--- a/src/aegis/src/aegis_server.erl
+++ b/src/aegis/src/aegis_server.erl
@@ -40,13 +40,16 @@
 ]).
 
 
-
 -define(KEY_CHECK, aegis_key_check).
 -define(INIT_TIMEOUT, 60000).
 -define(TIMEOUT, 10000).
+-define(CACHE_LIMIT, 100000).
+-define(CACHE_MAX_AGE_SEC, 1800).
+-define(CACHE_EXPIRATION_CHECK_SEC, 10).
+-define(LAST_ACCESSED_INACTIVITY_SEC, 10).
 
 
--record(entry, {uuid, encryption_key}).
+-record(entry, {uuid, encryption_key, counter, last_accessed, expires_at}).
 
 
 start_link() ->
@@ -84,8 +87,10 @@ encrypt(#{} = Db, Key, Value) when is_binary(Key), is_binary(Value) ->
         uuid := UUID
     } = Db,
 
-    case ets:member(?KEY_CHECK, UUID) of
-        true ->
+    Now = fabric2_util:now(sec),
+
+    case ets:lookup(?KEY_CHECK, UUID) of
+        [{UUID, ExpiresAt}] when ExpiresAt >= Now ->
             case gen_server:call(?MODULE, {encrypt, Db, Key, Value}) of
                 CipherText when is_binary(CipherText) ->
                     CipherText;
@@ -95,7 +100,7 @@ encrypt(#{} = Db, Key, Value) when is_binary(Key), is_binary(Value) ->
                 {error, Reason} ->
                     erlang:error(Reason)
             end;
-        false ->
+        _ ->
             process_flag(sensitive, true),
 
             {ok, DbKey} = do_open_db(Db),
@@ -109,8 +114,10 @@ decrypt(#{} = Db, Key, Value) when is_binary(Key), is_binary(Value) ->
         uuid := UUID
     } = Db,
 
-    case ets:member(?KEY_CHECK, UUID) of
-        true ->
+    Now = fabric2_util:now(sec),
+
+    case ets:lookup(?KEY_CHECK, UUID) of
+        [{UUID, ExpiresAt}] when ExpiresAt >= Now ->
             case gen_server:call(?MODULE, {decrypt, Db, Key, Value}) of
                 PlainText when is_binary(PlainText) ->
                     PlainText;
@@ -120,7 +127,7 @@ decrypt(#{} = Db, Key, Value) when is_binary(Key), is_binary(Value) ->
                 {error, Reason} ->
                     erlang:error(Reason)
             end;
-        false ->
+        _ ->
             process_flag(sensitive, true),
 
             {ok, DbKey} = do_open_db(Db),
@@ -133,10 +140,16 @@ decrypt(#{} = Db, Key, Value) when is_binary(Key), is_binary(Value) ->
 init([]) ->
     process_flag(sensitive, true),
     Cache = ets:new(?MODULE, [set, private, {keypos, #entry.uuid}]),
+    ByAccess = ets:new(?MODULE,
+        [ordered_set, private, {keypos, #entry.counter}]),
     ets:new(?KEY_CHECK, [named_table, protected, {read_concurrency, true}]),
 
+    erlang:send_after(0, self(), maybe_remove_expired),
+
     St = #{
-        cache => Cache
+        cache => Cache,
+        by_access => ByAccess,
+        counter => 0
     },
     {ok, St, ?INIT_TIMEOUT}.
 
@@ -145,16 +158,13 @@ terminate(_Reason, _St) ->
     ok.
 
 
-handle_call({insert_key, UUID, DbKey}, _From, #{cache := Cache} = St) ->
-    ok = insert(Cache, UUID, DbKey),
-    {reply, ok, St, ?TIMEOUT};
+handle_call({insert_key, UUID, DbKey}, _From, St) ->
+    NewSt = insert(St, UUID, DbKey),
+    {reply, ok, NewSt, ?TIMEOUT};
 
 handle_call({encrypt, #{uuid := UUID} = Db, Key, Value}, From, St) ->
-    #{
-        cache := Cache
-    } = St,
 
-    {ok, DbKey} = lookup(Cache, UUID),
+    {ok, DbKey} = lookup(St, UUID),
 
     erlang:spawn(fun() ->
         process_flag(sensitive, true),
@@ -172,11 +182,8 @@ handle_call({encrypt, #{uuid := UUID} = Db, Key, Value}, From, St) ->
     {noreply, St, ?TIMEOUT};
 
 handle_call({decrypt, #{uuid := UUID} = Db, Key, Value}, From, St) ->
-    #{
-        cache := Cache
-    } = St,
 
-    {ok, DbKey} = lookup(Cache, UUID),
+    {ok, DbKey} = lookup(St, UUID),
 
     erlang:spawn(fun() ->
         process_flag(sensitive, true),
@@ -197,10 +204,21 @@ handle_call(_Msg, _From, St) ->
     {noreply, St}.
 
 
+handle_cast({accessed, UUID}, St) ->
+    NewSt = bump_last_accessed(St, UUID),
+    {noreply, NewSt};
+
+
 handle_cast(_Msg, St) ->
     {noreply, St}.
 
 
+handle_info(maybe_remove_expired, St) ->
+    remove_expired_entries(St),
+    CheckInterval = timer:seconds(expiration_check_interval()),
+    erlang:send_after(CheckInterval, self(), maybe_remove_expired),
+    {noreply, St};
+
 handle_info(_Msg, St) ->
     {noreply, St}.
 
@@ -259,17 +277,125 @@ do_decrypt(DbKey, #{uuid := UUID}, Key, Value) ->
 
 %% cache functions
 
-insert(Cache, UUID, DbKey) ->
-    Entry = #entry{uuid = UUID, encryption_key = DbKey},
+insert(St, UUID, DbKey) ->
+    #{
+        cache := Cache,
+        by_access := ByAccess,
+        counter := Counter
+    } = St,
+
+    Now = fabric2_util:now(sec),
+    ExpiresAt = Now + max_age(),
+
+    Entry = #entry{
+        uuid = UUID,
+        encryption_key = DbKey,
+        counter = Counter,
+        last_accessed = Now,
+        expires_at = ExpiresAt
+    },
+
     true = ets:insert(Cache, Entry),
-    true = ets:insert(?KEY_CHECK, {UUID, true}),
-    ok.
+    true = ets:insert_new(ByAccess, Entry),
+    true = ets:insert(?KEY_CHECK, {UUID, ExpiresAt}),
+
+    maybe_evict_old_entries(St),
 
+    St#{counter := Counter + 1}.
 
-lookup(Cache, UUID) ->
+
+lookup(#{cache := Cache}, UUID) ->
     case ets:lookup(Cache, UUID) of
-        [#entry{uuid = UUID, encryption_key = DbKey}] ->
+        [#entry{uuid = UUID, encryption_key = DbKey} = Entry] ->
+            maybe_bump_last_accessed(Entry),
             {ok, DbKey};
         [] ->
             {error, not_found}
     end.
+
+
+maybe_bump_last_accessed(#entry{last_accessed = LastAccessed} = Entry) ->
+    case fabric2_util:now(sec) > LastAccessed + ?LAST_ACCESSED_INACTIVITY_SEC of
+        true ->
+            gen_server:cast(?MODULE, {accessed, Entry#entry.uuid});
+        false ->
+            ok
+    end.
+
+
+bump_last_accessed(St, UUID) ->
+    #{
+        cache := Cache,
+        by_access := ByAccess,
+        counter := Counter
+    } = St,
+
+
+    [#entry{counter = OldCounter} = Entry0] = ets:lookup(Cache, UUID),
+
+    Entry = Entry0#entry{
+        last_accessed = fabric2_util:now(sec),
+        counter = Counter
+    },
+
+    true = ets:insert(Cache, Entry),
+    true = ets:insert_new(ByAccess, Entry),
+
+    ets:delete(ByAccess, OldCounter),
+
+    St#{counter := Counter + 1}.
+
+
+maybe_evict_old_entries(#{cache := Cache} = St) ->
+    CacheLimit = cache_limit(),
+    CacheSize = ets:info(Cache, size),
+    evict_old_entries(St, CacheSize - CacheLimit).
+
+
+evict_old_entries(St, N) when N > 0 ->
+    #{
+        cache := Cache,
+        by_access := ByAccess
+    } = St,
+
+    OldestKey = ets:first(ByAccess),
+    [#entry{uuid = UUID}] = ets:lookup(ByAccess, OldestKey),
+    true = ets:delete(?KEY_CHECK, UUID),
+    true = ets:delete(Cache, UUID),
+    true = ets:delete(ByAccess, OldestKey),
+    evict_old_entries(St, N - 1);
+
+evict_old_entries(_St, _) ->
+    ok.
+
+
+remove_expired_entries(St) ->
+    #{
+        cache := Cache,
+        by_access := ByAccess
+    } = St,
+
+    MatchConditions = [{'=<', '$1', fabric2_util:now(sec)}],
+
+    KeyCheckMatchHead = {'_', '$1'},
+    KeyCheckExpired = [{KeyCheckMatchHead, MatchConditions, [true]}],
+    Count = ets:select_delete(?KEY_CHECK, KeyCheckExpired),
+
+    CacheMatchHead = #entry{expires_at = '$1', _ = '_'},
+    CacheExpired = [{CacheMatchHead, MatchConditions, [true]}],
+    Count = ets:select_delete(Cache, CacheExpired),
+    Count = ets:select_delete(ByAccess, CacheExpired).
+
+
+
+max_age() ->
+    config:get_integer("aegis", "cache_max_age_sec", ?CACHE_MAX_AGE_SEC).
+
+
+expiration_check_interval() ->
+    config:get_integer(
+        "aegis", "cache_expiration_check_sec", ?CACHE_EXPIRATION_CHECK_SEC).
+
+
+cache_limit() ->
+    config:get_integer("aegis", "cache_limit", ?CACHE_LIMIT).
diff --git a/src/aegis/test/aegis_server_test.erl b/src/aegis/test/aegis_server_test.erl
index 0f23a3f..0f96798 100644
--- a/src/aegis/test/aegis_server_test.erl
+++ b/src/aegis/test/aegis_server_test.erl
@@ -163,3 +163,152 @@ test_disabled_decrypt() ->
     Db = ?DB#{is_encrypted => aegis_server:open_db(?DB)},
     Decrypted = aegis:decrypt(Db, <<1:64>>, ?ENCRYPTED),
     ?assertEqual(?ENCRYPTED, Decrypted).
+
+
+
+lru_cache_with_expiration_test_() ->
+    {
+        foreach,
+        fun() ->
+            %% this has to be be set before start of aegis server
+            %% for config param "cache_expiration_check_sec" to be picked up
+            meck:new([config, aegis_server, fabric2_util], [passthrough]),
+            ok = meck:expect(config, get_integer, fun
+                ("aegis", "cache_limit", _) -> 5;
+                ("aegis", "cache_max_age_sec", _) -> 130;
+                ("aegis", "cache_expiration_check_sec", _) -> 1;
+                (_, _, Default) -> Default
+            end),
+            Ctx = setup(),
+            ok = meck:expect(fabric2_util, now, fun(sec) ->
+                get(time) == undefined andalso put(time, 10),
+                Now = get(time),
+                put(time, Now + 10),
+                Now
+            end),
+            Ctx
+        end,
+        fun teardown/1,
+        [
+            {"counter moves forward on access bump",
+            {timeout, ?TIMEOUT, fun test_advance_counter/0}},
+            {"oldest entries evicted",
+            {timeout, ?TIMEOUT, fun test_evict_old_entries/0}},
+            {"access bump preserves entries",
+            {timeout, ?TIMEOUT, fun test_bump_accessed/0}},
+            {"expired entries removed",
+            {timeout, ?TIMEOUT, fun test_remove_expired/0}}
+        ]
+    }.
+
+
+test_advance_counter() ->
+    ?assertEqual(0, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    ok = meck:expect(aegis_server, handle_cast, fun({accessed, _} = Msg, St) ->
+        #{counter := Counter} = St,
+        get(counter) == undefined andalso put(counter, 0),
+        OldCounter = get(counter),
+        put(counter, Counter),
+        ?assert(Counter > OldCounter),
+        meck:passthrough([Msg, St])
+    end),
+
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<I:64>>, ?VALUE),
+        aegis_server:encrypt(Db, <<(I+1):64>>, ?VALUE)
+    end, lists:seq(1, 10)),
+
+    ?assertEqual(10, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)).
+
+
+test_evict_old_entries() ->
+    ?assertEqual(0, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% overflow cache
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<I:64>>, ?VALUE)
+    end, lists:seq(1, 10)),
+
+    ?assertEqual(10, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% confirm that newest keys are still in cache
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<(I+1):64>>, ?VALUE)
+    end, lists:seq(6, 10)),
+
+    ?assertEqual(10, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% confirm that oldest keys been eviced and needed re-fetch
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<(I+1):64>>, ?VALUE)
+    end, lists:seq(1, 5)),
+
+    ?assertEqual(15, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)).
+
+
+test_bump_accessed() ->
+    ?assertEqual(0, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% fill the cache
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<I:64>>, ?VALUE)
+    end, lists:seq(1, 5)),
+
+    ?assertEqual(5, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% bump oldest key and then insert a new key to trigger eviction
+    aegis_server:encrypt(?DB#{uuid => <<1:64>>}, <<1:64>>, ?VALUE),
+    aegis_server:encrypt(?DB#{uuid => <<6:64>>}, <<6:64>>, ?VALUE),
+    ?assertEqual(6, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% confirm that former oldest key is still in cache
+    aegis_server:encrypt(?DB#{uuid => <<1:64>>}, <<2:64>>, ?VALUE),
+    ?assertEqual(6, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% confirm that the second oldest key been evicted by the new insert
+    aegis_server:encrypt(?DB#{uuid => <<2:64>>}, <<3:64>>, ?VALUE),
+    ?assertEqual(7, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)).
+
+
+test_remove_expired() ->
+    ?assertEqual(0, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% to detect when maybe_remove_expired called
+    ok = meck:expect(aegis_server, handle_info,fun
+        (maybe_remove_expired, St) ->
+            meck:passthrough([maybe_remove_expired, St])
+    end),
+
+    %% fill the cache. first key expires a 140, last at 180 of "our" time
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<I:64>>, ?VALUE)
+    end, lists:seq(1, 5)),
+
+    ?assertEqual(5, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% confirm enties are still in cache and wind up our "clock" to 160
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<I:64>>, ?VALUE)
+    end, lists:seq(1, 5)),
+
+    ?assertEqual(5, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)),
+
+    %% wait for remove_expired_entries to be triggered
+    meck:reset(aegis_server),
+    meck:wait(aegis_server, handle_info, [maybe_remove_expired, '_'], 2500),
+
+    %% 3 "oldest" entries should be removed, 2 yet to expire still in cache
+    lists:foreach(fun(I) ->
+        Db = ?DB#{uuid => <<I:64>>},
+        aegis_server:encrypt(Db, <<I:64>>, ?VALUE)
+    end, lists:seq(1, 5)),
+
+    ?assertEqual(8, meck:num_calls(?AEGIS_KEY_MANAGER, open_db, 1)).
diff --git a/src/fabric/src/fabric2_util.erl b/src/fabric/src/fabric2_util.erl
index 9b6d18c..136762b 100644
--- a/src/fabric/src/fabric2_util.erl
+++ b/src/fabric/src/fabric2_util.erl
@@ -41,6 +41,7 @@
     all_docs_view_opts/1,
 
     iso8601_timestamp/0,
+    now/1,
     do_recovery/0,
 
     pmap/2,
@@ -348,6 +349,13 @@ iso8601_timestamp() ->
     io_lib:format(Format, [Year, Month, Date, Hour, Minute, Second]).
 
 
+now(ms) ->
+    {Mega, Sec, Micro} = os:timestamp(),
+    (Mega * 1000000 + Sec) * 1000 + round(Micro / 1000);
+now(sec) ->
+    now(ms) div 1000.
+
+
 do_recovery() ->
     config:get_boolean("couchdb",
         "enable_database_recovery", false).