Allow custom cache objects using callback modules

This patch allows custom objects derived from design documents to be
exposed in the ddoc_cache. A custom object is defined by a module
exporting the recover/1 function which accepts the name of a database
and returns {ok, term()}. The term will be cached as if it were a design
doc. For the sake of simplicity all custom cache objects associated with
a database are evicted when any ddoc in the database changes.

Once a custom callback module has been defined the object associated
with the module can be retrieved via ddoc_cache:open(DbName, Mod).

BugzID: 42707
diff --git a/src/ddoc_cache.erl b/src/ddoc_cache.erl
index 039a0a0..ed93309 100644
--- a/src/ddoc_cache.erl
+++ b/src/ddoc_cache.erl
@@ -75,12 +75,28 @@
             ddoc_cache_opener:recover_validation_funs(DbName)
     end.
 
+open_custom(DbName, Mod) ->
+    Key = {DbName, Mod},
+    case ddoc_cache_opener:lookup(Key) of
+        {ok, _} = Resp ->
+            couch_stats:increment_counter([ddoc_cache, hit]),
+            Resp;
+        missing ->
+            couch_stats:increment_counter([ddoc_cache, miss]),
+            ddoc_cache_opener:open_doc(DbName, Mod);
+        recover ->
+            couch_stats:increment_counter([ddoc_cache, recovery]),
+            Mod:recover(DbName)
+    end.
+
 evict(ShardDbName, DDocIds) ->
     DbName = mem3:dbname(ShardDbName),
     ddoc_cache_opener:evict_docs(DbName, DDocIds).
 
 open(DbName, validation_funs) ->
     open_validation_funs(DbName);
+open(DbName, Module) when is_atom(Module) ->
+    open_custom(DbName, Module);
 open(DbName, <<"_design/", _/binary>>=DDocId) when is_binary(DbName) ->
     open_doc(DbName, DDocId);
 open(DbName, DDocId) when is_binary(DDocId) ->
diff --git a/src/ddoc_cache_opener.erl b/src/ddoc_cache_opener.erl
index 1ef3ec8..0236921 100644
--- a/src/ddoc_cache_opener.erl
+++ b/src/ddoc_cache_opener.erl
@@ -187,7 +187,10 @@
     handle_cast({do_evict, DbName, DDocIds}, St);
 
 handle_cast({do_evict, DbName, DDocIds}, St) ->
-    ets_lru:remove(?CACHE, {DbName, validation_funs}),
+    CustomKeys = lists:flatten(ets_lru:match(?CACHE, {DbName, '$1'}, '_')),
+    lists:foreach(fun(Mod) ->
+        ets_lru:remove(?CACHE, {DbName, Mod})
+    end, CustomKeys),
     lists:foreach(fun(DDocId) ->
         Revs = ets_lru:match(?CACHE, {DbName, DDocId, '$1'}, '_'),
         lists:foreach(fun([Rev]) ->
@@ -230,12 +233,26 @@
     {ok, State}.
 
 -spec fetch_doc_data({dbname(), validation_funs}) -> no_return();
+                    ({dbname(), atom()}) -> no_return();
                     ({dbname(), docid()}) -> no_return();
                     ({dbname(), docid(), revision()}) -> no_return().
 fetch_doc_data({DbName, validation_funs}=OpenerKey) ->
     {ok, Funs} = recover_validation_funs(DbName),
     ok = ets_lru:insert(?CACHE, OpenerKey, Funs),
     exit({open_ok, OpenerKey, {ok, Funs}});
+fetch_doc_data({DbName, Mod}=OpenerKey) when is_atom(Mod) ->
+    % This is not actually a docid but rather a custom cache key.
+    % Treat the argument as a code module and invoke its recover function.
+    try Mod:recover(DbName) of
+        {ok, Result} ->
+            ok = ets_lru:insert(?CACHE, OpenerKey, Result),
+            exit({open_ok, OpenerKey, {ok, Result}});
+        Else ->
+            exit({open_ok, OpenerKey, Else})
+    catch
+        Type:Reason ->
+            exit({open_error, OpenerKey, Type, Reason})
+    end;
 fetch_doc_data({DbName, DocId}=OpenerKey) ->
     try recover_doc(DbName, DocId) of
         {ok, Doc} ->