Stop using shelve
authorJack Miller <jack@codezen.org>
Tue, 16 Jun 2015 20:53:25 +0000 (15:53 -0500)
committerJack Miller <jack@codezen.org>
Wed, 17 Jun 2015 21:39:23 +0000 (16:39 -0500)
NOTE: This will automatically migrate old shelves.

- Copying shelve dbs is a fucking pain and has caused breakage when
  moving from one distro / OS to another when the available lightweight
DBs change.

- We use basically no database features, except the "cache" which is
  really just the DB holding the entire database in memory which we can
also do with greater control (there has been 0 change in memory usage
with this patch).

- We have to workaround the database constantly expanding in size with
  some databases (GDBM requires reorganize())

- Shelves don't (as far as I can see) play well with transparent
  compression and it doesn't look like it does any compression itself,
so by just gzipping raw JSON, we can shrink the feeds file by 80%.

- Internally, shelves use pickle, which is a Python only serialization.
  This isn't important at the moment, but gzipped JSON is platform
agnostic.

- Replace my feed dump script with zcat =P.

canto_next/canto_backend.py
canto_next/storage.py

index 1f09f06..dbb7288 100644 (file)
@@ -14,7 +14,7 @@ from .feed import allfeeds, wlock_feeds, rlock_feeds, wlock_all, wunlock_all, rl
 from .encoding import encoder
 from .server import CantoServer
 from .config import config, parse_locks, parse_unlocks
-from .storage import CantoShelf, CACHE_OFF, CACHE_ALWAYS, CACHE_ON_CONNS
+from .storage import CantoShelf
 from .fetch import CantoFetch
 from .hooks import on_hook, call_hook
 from .tag import alltags
@@ -79,7 +79,6 @@ class CantoBackend(PluginHandler, CantoServer):
         self.socket_transforms = {}
 
         self.shelf = None
-        self.caching = CACHE_OFF
 
         # No bad arguments.
         version = "canto-daemon " + REPLACE_VERSION + " " + GIT_HASH
@@ -634,12 +633,7 @@ class CantoBackend(PluginHandler, CantoServer):
         print("\t-v/\t\tVerbose logging (for debug)")
         print("\t-D/--dir <dir>\tSet configuration directory.")
         print("\t-n/--nofetch\tJust serve content, don't fetch new content.")
-        print("\n\t-c/--cache [on|off|conn]")
-        print("\t\tControl memory usage v. performance")
-        print("\t\t\ton - keep data in memory always (fastest)")
-        print("\t\t\tconn - keep data in memory with active connection")
-        print("\t\t\toff - keep data mostly on disk (default)")
-        print("\nPlugin control\n")
+        print("\n\nPlugin control\n")
         print("\t--noplugins\t\t\t\tDisable plugins")
         print("\t--enableplugins 'plugin1 plugin2...'\tEnable single plugins (overrides --noplugins)")
         print("\t--disableplugins 'plugin1 plugin2...'\tDisable single plugins")
@@ -656,16 +650,6 @@ class CantoBackend(PluginHandler, CantoServer):
             elif opt in ['-h', '--help']:
                 self.print_help()
                 sys.exit(0)
-            elif opt in ['-c', '--cache']:
-                if arg == "conn":
-                    self.caching = CACHE_ON_CONNS
-                elif arg == "on":
-                    self.caching = CACHE_ALWAYS
-                elif arg == "off":
-                    self.caching = CACHE_OFF
-                else:
-                    print("Unknown cache setting: %s" % arg)
-                    sys.exit(-1)
         return 0
 
     # SIGINT, take our time, exit cleanly
@@ -812,7 +796,7 @@ class CantoBackend(PluginHandler, CantoServer):
     # fatal and handled lower in CantoShelf.
 
     def get_storage(self):
-        self.shelf = CantoShelf(self.feed_path, self.caching)
+        self.shelf = CantoShelf(self.feed_path)
 
     # Bring up config, the only errors possible at this point will
     # be fatal and handled lower in CantoConfig.
index 84113a7..24c5186 100644 (file)
@@ -13,7 +13,8 @@ from .hooks import on_hook, call_hook
 import threading
 import traceback
 import logging
-import shelve
+import json
+import gzip
 import time
 import dbm
 import sys
@@ -21,99 +22,68 @@ import os
 
 log = logging.getLogger("SHELF")
 
-CACHE_OFF = 0
-CACHE_ON_CONNS = 1
-CACHE_ALWAYS = 2
-
 class CantoShelf():
-    def __init__(self, filename, caching):
+    def __init__(self, filename):
         self.filename = filename
-        self.caching = caching
 
-        self.index = []
         self.cache = {}
 
-        self.has_conns = False
         self.open()
 
-        if self.caching == CACHE_ON_CONNS:
-            on_hook("server_first_connection", self.on_first_conn)
-            on_hook("server_no_connections", self.on_no_conns)
-
-    @wlock_feeds
-    def on_first_conn(self):
-        log.debug("Heating cache.")
-
-        self.index = []
-        for item in self.shelf:
-            self.cache[item] = self.shelf[item]
-            self.index.append(item)
-
-        self.has_conns = True
-
-    def on_no_conns(self):
-        log.debug("Killing cache.")
-        self.has_conns = False
-        self.sync()
-
     def check_control_data(self):
-        if "control" not in self.shelf:
-            self.shelf["control"] = {}
+        if "control" not in self.cache:
+            self.cache["control"] = {}
 
         for ctrl_field in ["canto-modified","canto-user-modified"]:
-            if ctrl_field not in self.shelf["control"]:
-                self.shelf["control"][ctrl_field] = 0
+            if ctrl_field not in self.cache["control"]:
+                self.cache["control"][ctrl_field] = 0
 
     @wlock_feeds
     def open(self):
         call_hook("daemon_db_open", [self.filename])
 
-        mode = 'c'
-        if dbm.whichdb(self.filename) == 'dbm.gnu':
-            mode += 'u'
-
-        self.shelf = shelve.open(self.filename, mode)
+        if not os.path.exists(self.filename):
+            fp = gzip.open(self.filename, "wt", 9, "UTF-8")
+            json.dump(self.cache, fp)
+            fp.close()
+        else:
+            fp = gzip.open(self.filename, "rt", 9, "UTF-8")
+            try:
+                self.cache = json.load(fp)
+            except:
+                log.info("Failed to JSON load, old shelf?")
+                try:
+                    import shelve
+                    s = shelve.open(self.filename, "r")
+                    for key in s:
+                        self.cache[key] = s[key]
+                except Exception as e:
+                    log.error("Failed to migrate old shelf: %s", e)
+                    raise
+                log.info("Migrated old shelf")
+            finally:
+                fp.close()
 
         self.check_control_data()
 
-        if self.caching == CACHE_ALWAYS or\
-                (self.caching == CACHE_ON_CONNS and self.has_conns):
-            for key in self.shelf:
-                self.cache[key] = self.shelf[key]
-
-        self.index = list(self.shelf.keys())
-
-        log.debug("Shelf opened: %s", self.shelf)
-
     def __setitem__(self, name, value):
         self.cache[name] = value
         self.update_mod()
 
     def __getitem__(self, name):
-        if name in self.cache:
-            return self.cache[name]
-        return self.shelf[name]
+        return self.cache[name]
 
     def __contains__(self, name):
-        if name in self.cache:
-            return True
-        if name in self.index:
-            return True
-        return False
+        return name in self.cache
 
     def __delitem__(self, name):
         if name in self.cache:
             del self.cache[name]
-
-        if name in self.index:
-            self.index.remove(name)
-            del self.shelf[name]
-
         self.update_mod()
 
     def update_umod(self):
         if "control" not in self.cache:
-            self.cache["control"] = self.shelf['control']
+            self.cache["control"] = self.cache['control']
 
         ts = int(time.mktime(time.gmtime()))
         self.cache["control"]["canto-user-modified"] = ts
@@ -121,7 +91,7 @@ class CantoShelf():
 
     def update_mod(self):
         if "control" not in self.cache:
-            self.cache["control"] = self.shelf['control']
+            self.cache["control"] = self.cache['control']
 
         ts = int(time.mktime(time.gmtime()))
         self.cache["control"]["canto-modified"] = ts
@@ -129,49 +99,19 @@ class CantoShelf():
     @wlock_feeds
     def sync(self):
 
-        # Check here in case we're called after close by plugins that
-        # don't know better.
-        if self.shelf == None:
-            log.debug("No shelf.")
-            return
-
-        for key in self.cache:
-            self.shelf[key] = self.cache[key]
+        # If we get a sync after we're closed, or before we're open
+        # just ignore it.
 
-        if self.caching == CACHE_OFF or\
-                (self.caching == CACHE_ON_CONNS and not self.has_conns):
-            self.cache = {}
-            log.debug("Unloaded.")
+        if self.cache == {}:
+            return
 
-        self.shelf.sync()
+        fp = gzip.open(self.filename, "wt", 9, "UTF-8")
+        json.dump(self.cache, fp, indent=4, sort_keys=True)
+        fp.close()
         log.debug("Synced.")
 
-        if dbm.whichdb(self.filename) == 'dbm.gnu':
-            self.shelf.close()
-            self._reorganize()
-            self.open()
-
-    def _reorganize(self):
-        # This is a workaround for shelves implemented with database types
-        # (like gdbm) that won't shrink themselves.
-
-        # Because we're a delete heavy workload (as we drop items that are no
-        # longer relevant), we check for reorganize() and use it on close,
-        # which should shrink the DB and keep it from growing into perpetuity.
-
-        try:
-            db = dbm.open(self.filename, "wu")
-            getattr(db, 'reorganize')()
-            db.close()
-        except Exception as e:
-            log.warn("Failed to reorganize db:")
-            log.warn(traceback.format_exc())
-        else:
-            log.debug("Successfully trimmed db")
-
     def close(self):
         log.debug("Closing.")
         self.sync()
-        self.shelf.close()
-        self.shelf = None
+        self.cache = {}
         call_hook("daemon_db_close", [self.filename])