diff --git a/MANIFEST.in b/MANIFEST.in
index be4a825561e7c94813eaf2b9db2cab18f9ba7cb8..a8495446b771f87686bf6e09307093d7edd9e5d6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1 +1,2 @@
 recursive-include inventory_provider/static *
+include inventory_provider/logging_default_config.json
diff --git a/README.md b/README.md
index 93ef44eea42beb5978bfc3bef875c0abf6f8551e..774e59d40a7f1d8830855cb1547a71fdfa01a6a3 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
 
-1. [Inventory Provider]
-   1. [Overview]
-   2. [Configuration]
-   3. [Running this module]
-   4. [Protocol specification]
-   5. [backend (Redis) storage schema]
+* [Inventory Provider](#inventory-provider)
+  * [Overview](#overview)
+  * [Configuration](#configuration)
+  * [Running this module](#running-this-module)
+  * [Protocol Specification](#protocol-specification)
+  * [Backend (Redis) Storage Schema](#backend-redis-storage-schema)
 
 
 
@@ -42,18 +42,19 @@ The following is an example:
 
 ```python
 INVENTORY_PROVIDER_CONFIG_FILENAME = "/somepath/config.json"
+ENABLE_TESTING_ROUTES = True
 ```
 
-- `INVENTORY_PROVIDER_CONFIG_FILENAME`: run-time accessible filename
+- `INVENTORY_PROVIDER_CONFIG_FILENAME`: [REQUIRED] Run-time accessible filename
 of a json file containing the server configuration parameters.  This file
 must be formatted according to the following json schema:
 
     ```json
     {
         "$schema": "http://json-schema.org/draft-07/schema#",
-        "type": "object",
-        "properties": {
-            "alarms-db": {
+
+        "definitions": {
+            "database_credentials": {
                 "type": "object",
                 "properties": {
                     "hostname": {"type": "string"},
@@ -63,12 +64,19 @@ must be formatted according to the following json schema:
                 },
                 "required": ["hostname", "dbname", "username", "password"],
                 "additionalProperties": False
-            },
+
+            }
+        },
+
+        "type": "object",
+        "properties": {
+            "alarms-db": {"$ref": "#/definitions/database_credentials"},
+            "ops-db": {"$ref": "#/definitions/database_credentials"},
             "oid_list.conf": {"type": "string"},
-            "routers_community.conf": {"type": "string"},
             "ssh": {
                 "type": "object",
                 "properties": {
+                    "username": {"type": "string"},
                     "private-key": {"type": "string"},
                     "known-hosts": {"type": "string"}
                 },
@@ -83,19 +91,57 @@ must be formatted according to the following json schema:
                 },
                 "required": ["hostname", "port"],
                 "additionalProperties": False
+            },
+            "junosspace": {
+                "api": {"type": "string"},
+                "username": {"type": "string"},
+                "password": {"type": "string"}
+            },
+            "infinera-dna": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "name": {"type": "string"},
+                        "address": {"type": "string"}
+                    },
+                    "required": ["name", "address"],
+                    "additionalProperties": False
+                }
+            },
+            "coriant-tnms": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "name": {"type": "string"},
+                        "address": {"type": "string"}
+                    },
+                    "required": ["name", "address"],
+                    "additionalProperties": False
+                }
             }
         },
         "required": [
             "alarms-db",
+            "ops-db",
             "oid_list.conf",
-            "routers_community.conf",
             "ssh",
-            "redis"],
+            "redis",
+            "junosspace",
+            "infinera-dna",
+            "coriant-tnms"],
         "additionalProperties": False
     }
     ```
 
 
+- `ENABLE_TESTING_ROUTES`: [OPTIONAL (default value: False)]
+Flat (can be any value that evaluates to True) to enable
+routes to special utilities used for testing.
+*This must never be enabled in a production environment.*
+
+
 ## Running this module
 
 This module has been tested in the following execution environments:
@@ -111,10 +157,14 @@ $ flask run
 
 - As an Apache/`mod_wsgi` service.
   - Details of Apache and `mod_wsgi`
-configuration are beyond the scope of this document.
+    configuration are beyond the scope of this document.
 
+- As a `gunicorn` wsgi service.
+  - Details of `gunicorn` configuration are
+    beyond the scope of this document.
 
-## protocol specification
+
+## Protocol Specification
 
 The following resources can be requested from the webservice.
 
@@ -253,52 +303,58 @@ Any non-empty responses are JSON formatted messages.
 * /jobs/update
 
   This resource updates the inventory network data for juniper devices.
+  The function completes asynchronously and a list of outstanding
+  task id's is returned so the caller can
+  use `/jobs/check-task-status` to determine when all jobs
+  are finished.  The response will be formatted as follows:
+
+  ```json
+  {
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "type": "array",
+    "items": {"type": "string"}
+  }
+  ```
 
-* /jobs/update-startup
+* /jobs/reload-router-config/<equipment-name>
 
-  This resource updates data that should only be refreshed
-  in case of system restart.
-  
-* /jobs/update-interfaces-to-services
+  This resource updates the inventory network data for
+  the identified juniper device.  This function completes
+  asynchronously and returns the same value as
+  `/jobs/update`, except the return contains exactly
+  one task id.
 
-  This resource updates the information that lists all the services for
-  interfaces found in the external inventory system
-  
-* /jobs/update-service-hierarchy
+* /jobs/check-task-status/<task-id>"
 
-  This resource updates all the information showing which services are related
-  to one-another
-  
-* /jobs/update-equipment-locations
+  This resource returns the current status of
+   an asynchronous task started by `/jobs/update`
+   or `jobs/reload-router-config`.  The return value
+   will be formatted as follows:
+
+  ```json
+  {
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "type": "object",
+    "properties": {
+        "id": {"type": "string"},
+        "status": {"type": "string"},
+        "exception": {"type": "boolean"},
+        "ready": {"type": "boolean"},
+        "success": {"type": "boolean"},
+        "result": {"type": "object"}
+    },
+    "required": ["id", "status", "exception", "ready", "success"],
+    "additionalProperties": False
+  }
+  ```
 
-  This resource loads the location information for all the equipment from the
-  external inventory system
-  
-* /jobs/update-from-inventory-system
-  
-  This resource updates the inventory data from the external inventory system,
-  it is a short cut for executing the above three jobs
-  
 * /jobs/update-interface-statuses
 
   This resource updates the last known statuses of all interfaces extracted
   from the external inventory system, status information is retrieved from the
   alarms database
 
-* /classifier/infinera-dna-addresses, /classifier/juniper-server-addresses
 
-  Both of these resources return lists of source addresses
-  of known senders of snmp traps.  Responses will be
-  formatted as follows:
-
-    ```json
-    {
-        "$schema": "http://json-schema.org/draft-07/schema#",
-        "type": "array",
-        "items": {"type": "string"}
-    }
-    ```
-    
 * /classifier/*`type`*/*`source-equipment`*/*`source-interface`*
 
   The source-equipment is the equipment that causes the trap, not the NMS that
@@ -356,15 +412,44 @@ Any non-empty responses are JSON formatted messages.
     }
     ```
 
-## backend (Redis) storage schema
 
-`netconf:<hostname>`
+### Testing utilities
+
+The following routes are only available if the server
+was started with the `ENABLE_TESTING_ROUTES` flag.
+
+
+* `/testing/flushdb`
+
+  This method erases all data in the backend redis
+  database.
+
+*  /testing/infinera-dna-addresses, /testing/coriant-tnmp-addresses, /testing/juniper-server-addresses`
+
+  All of these resources return lists of source addresses
+  of known senders of snmp traps.  Responses will be
+  formatted as follows:
+
+    ```json
+    {
+        "$schema": "http://json-schema.org/draft-07/schema#",
+        "type": "array",
+        "items": {"type": "string"}
+    }
+    ```
+
+
+## Backend (Redis) Storage Schema
+
+* `netconf:<hostname>`
+
   * key example
     * `netconf:mx1.ams.nl.geant.net`
   * value format
     * cf. validation in `inventory_provider.juniper.load_config`
 
-`snmp-interfaces:<hostname>`
+* `snmp-interfaces:<hostname>`
+
   * key example:
     * `snmp-interfaces:mx1.lon2.uk.geant.net`
   * value schema
@@ -379,20 +464,20 @@ Any non-empty responses are JSON formatted messages.
                 "properties": {
                     "v4Address": {
                         "type": "string",
-                        "pattern": "^(\d+\.){3}\d+$"
+                        "pattern": r'^(\d+\.){3}\d+$'
                     },
                     "v4Mask": {
                         "type": "string",
-                        "pattern": "^(\d+\.){3}\d+$"
+                        "pattern": r'^(\d+\.){3}\d+$'
                     },
-                    "v4InterfaceName": {:"type", "string"},
-                    "index": {`
+                    "v4InterfaceName": {"type": "string"},
+                    "index": {
                         "type": "string",
-                        "pattern": "^\d+$"
+                        "pattern": r'^\d+$'
                     }
                 },
                 "required": [
-                    "v4Address", "v4Mask", "v4InterfaceName", "index],
+                    "v4Address", "v4Mask", "v4InterfaceName", "index"],
                 "additionalProperties": False
             },
             "v6ifc": {
@@ -400,36 +485,36 @@ Any non-empty responses are JSON formatted messages.
                 "properties": {
                     "v6Address": {
                         "type": "string",
-                        "pattern": "^[\d:]+$"
+                        "pattern": r'^[a-f\d:]+$'
                     },
                     "v6Mask": {
                         "type": "string",
-                        "pattern": "^\d+$"
+                        "pattern": r'^\d+$'
                     },
-                    "v6InterfaceName": {:"type", "string"},
-                    "index": {`
+                    "v6InterfaceName": {"type": "string"},
+                    "index": {
                         "type": "string",
-                        "pattern": "^\d+$"
+                        "pattern": r'^\d+$'
                     }
                 },
                 "required": [
-                    "v6Address", "v6Mask", "v6InterfaceName", "index],
+                    "v6Address", "v6Mask", "v6InterfaceName", "index"],
                 "additionalProperties": False
             }
         },
 
         "type": "array",
          "items": {
-            "anyOf": {
-                "$ref": "#/definitions/v4Ifc",
-                "$ref": "#/definitions/v6Ifc"
-            }
+            "anyOf": [
+                {"$ref": "#/definitions/v4ifc"},
+                {"$ref": "#/definitions/v6ifc"}
+            ]
         }
     }
     ```
 
 
-`opsdb:interface_services:<equipment name>:<interface name>`
+* `opsdb:interface_services:<equipment name>:<interface name>`
 
   * key examples
     * `opsdb:interface_services:mx1.ams.nl.geant.net:ae15.1103`
@@ -504,7 +589,7 @@ Any non-empty responses are JSON formatted messages.
     ```
 
 
-`opsdb:location:<equipment name>`
+* `opsdb:location:<equipment name>`
 
   * key examples
     * `opsdb:location:mx1.ams.nl.geant.net`
@@ -535,8 +620,8 @@ Any non-empty responses are JSON formatted messages.
     }
     ```
 
-`opsdb:services:children:<circuit db id>`
-`opsdb:services:parents:<circuit db id>`
+* `opsdb:services:children:<circuit db id>`
+* `opsdb:services:parents:<circuit db id>`
 
   * key examples
     * `opsdb:services:children:12363`
@@ -588,7 +673,7 @@ Any non-empty responses are JSON formatted messages.
     }
     ```
 
-`alarmsdb:interface_status:<equipment name>:<interface name>`
+* `alarmsdb:interface_status:<equipment name>:<interface name>`
   * key examples
     * `alarmsdb:interface_status:Lab node 1:1-1/3`
     *  `alarmsdb:interface_status:mx1.ams.nl.geant.net:ae15.1500`
@@ -599,7 +684,7 @@ Any non-empty responses are JSON formatted messages.
     * `down`
 
 
-`ix_public_peer:<address>`
+* `ix_public_peer:<address>`
   * key examples
     * `ix_public_peer:193.203.0.203`
     * `ix_public_peer:2001:07f8:00a0:0000:0000:5926:0000:0002`
@@ -637,7 +722,7 @@ Any non-empty responses are JSON formatted messages.
     }
     ```
 
-`vpn_rr_peers/<address>`
+* `vpn_rr_peers/<address>`
   * key examples
     * `ix_public_peer:193.203.0.203`
   * valid values:
@@ -665,3 +750,55 @@ Any non-empty responses are JSON formatted messages.
         "additionalProperties": False
     }
     ```
+
+* `reverse_interface_addresses/<address>`
+  * key examples
+    * `reverse_interface_addresses:193.203.0.203`
+    * `reverse_interface_addresses:2001:07f8:00a0:0000:0000:5926:0000:0002`
+  * valid values:
+    ```json
+    {
+        "$schema": "http://json-schema.org/draft-07/schema#",
+
+        "definitions": {
+            "v4a": {
+                "type": "string",
+                "pattern": r'^(\d+\.){3}\d+$'
+            },
+            "v6a": {
+                "type": "string",
+                "pattern": r'^([a-f\d]{4}:){7}[a-f\d]{4}$'
+            },
+            "v4i": {
+                "type": "string",
+                "pattern": r'^(\d+\.){3}\d+/\d+$'
+            },
+            "v6i": {
+                "type": "string",
+                "pattern": r'^[a-f\d:]+/\d+$'
+            }
+        },
+
+        "type": "array",
+        "items": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "oneOf": [
+                        {"$ref": "#/definitions/v4a"},
+                        {"$ref": "#/definitions/v6a"}
+                    ]
+                },
+                "interface address": {
+                    "oneOf": [
+                        {"$ref": "#/definitions/v4i"},
+                        {"$ref": "#/definitions/v6i"}
+                    ]
+                },
+                "interface name": {"type": "string"},
+            },
+            "required": ["name", "interface address", "interface name"],
+            "additionalProperties": False
+        }
+    }
+    ```
diff --git a/changelog b/changelog
index c5d29d948e99d30748ddd49ad8a061404964b093..348bc6ac1eaa3a2182fcc12432df42ed9a06a664 100644
--- a/changelog
+++ b/changelog
@@ -25,3 +25,5 @@
      read snmp community string from netconf
      derive active router list from junosspace
      cache ix public & vpn rr peers
+     use external logging config file
+     added utilities for the test environment
diff --git a/inventory_provider/__init__.py b/inventory_provider/__init__.py
index 485cf81ce4288c4d353520c2245104d0fe839e2d..024fac841eb30cde1e859612856db2bc2b1e1293 100644
--- a/inventory_provider/__init__.py
+++ b/inventory_provider/__init__.py
@@ -14,6 +14,10 @@ def create_app():
     :return: a new flask app instance
     """
 
+    if "SETTINGS_FILENAME" not in os.environ:
+        assert False, \
+            "environment variable SETTINGS_FILENAME' must be defined"
+
     app = Flask(__name__)
     app.secret_key = "super secret session key"
 
@@ -26,18 +30,14 @@ def create_app():
     from inventory_provider.routes import jobs
     app.register_blueprint(jobs.routes, url_prefix='/jobs')
 
-    from inventory_provider.routes import opsdb
-    app.register_blueprint(opsdb.routes, url_prefix='/opsdb')
-
     from inventory_provider.routes import classifier
     app.register_blueprint(classifier.routes, url_prefix='/classifier')
 
     from inventory_provider.routes import poller
     app.register_blueprint(poller.routes, url_prefix='/poller')
 
-    if "SETTINGS_FILENAME" not in os.environ:
-        assert False, \
-            "environment variable SETTINGS_FILENAME' must be defined"
+    logging.info("initializing Flask with config from: %r"
+                 % os.environ["SETTINGS_FILENAME"])
     app.config.from_envvar("SETTINGS_FILENAME")
 
     assert "INVENTORY_PROVIDER_CONFIG_FILENAME" in app.config, (
@@ -48,11 +48,18 @@ def create_app():
         "config file '%s' not found" %
         app.config["INVENTORY_PROVIDER_CONFIG_FILENAME"])
 
+    if app.config.get('ENABLE_TESTING_ROUTES', False):
+        from inventory_provider.routes import testing
+        app.register_blueprint(testing.routes, url_prefix='/testing')
+        logging.warning('DANGER!!! testing routes enabled')
+
     from inventory_provider import config
     with open(app.config["INVENTORY_PROVIDER_CONFIG_FILENAME"]) as f:
         # test the config file can be loaded
+        logging.info("loading config from: %r"
+                     % app.config["INVENTORY_PROVIDER_CONFIG_FILENAME"])
         app.config["INVENTORY_PROVIDER_CONFIG"] = config.load(f)
 
-    logging.debug(app.config)
+    logging.info('Inventory Provider Flask app initialized')
 
     return app
diff --git a/inventory_provider/constants.py b/inventory_provider/constants.py
deleted file mode 100644
index f88475574e7b44dd3d17116027a2ba6c8588a0a6..0000000000000000000000000000000000000000
--- a/inventory_provider/constants.py
+++ /dev/null
@@ -1,4 +0,0 @@
-SNMP_LOGGER_NAME = "snmp-logger"
-JUNIPER_LOGGER_NAME = "juniper-logger"
-DATABASE_LOGGER_NAME = "database-logger"
-TASK_LOGGER_NAME = "task-logger"
diff --git a/inventory_provider/environment.py b/inventory_provider/environment.py
index ad4bcadcf93f39e75e403fab6fd2545ff46284d9..989c0a1355ebb7b0e44f110842b25f045868a47d 100644
--- a/inventory_provider/environment.py
+++ b/inventory_provider/environment.py
@@ -1,26 +1,24 @@
-import logging
+import json
+import logging.config
 import os
-import sys
-from inventory_provider import constants
-
-
-def _level_from_env(var_name, default_level=logging.INFO):
-    level_str = os.getenv(var_name, logging.getLevelName(default_level))
-    numeric_level = getattr(logging, level_str.upper(), default_level)
-    logging.debug('setting %s logging level to %s'
-                  % (var_name, logging.getLevelName(numeric_level)))
-    return numeric_level
 
 
 def setup_logging():
-    logging.basicConfig(
-        stream=sys.stderr,
-        level=_level_from_env('DEFAULT_LOGGING', logging.INFO))
-    logging.getLogger(constants.SNMP_LOGGER_NAME).setLevel(
-        _level_from_env('SNMP_LOGGING', logging.INFO))
-    logging.getLogger(constants.TASK_LOGGER_NAME).setLevel(
-        _level_from_env('TASK_LOGGING', logging.INFO))
-    logging.getLogger(constants.JUNIPER_LOGGER_NAME).setLevel(
-        _level_from_env('JUNIPER_LOGGING', logging.INFO))
-    logging.getLogger(constants.DATABASE_LOGGER_NAME).setLevel(
-        _level_from_env('DATABASE_LOGGING', logging.INFO))
+    """
+    set up logging using the configured filename
+
+    if LOGGING_CONFIG is defined in the environment, use this for
+    the filename, otherwise use logging_default_config.json
+    """
+    default_filename = os.path.join(
+        os.path.dirname(__file__),
+        'logging_default_config.json')
+    filename = os.getenv('LOGGING_CONFIG', default_filename)
+    with open(filename) as f:
+        # TODO: this mac workaround should be removed ...
+        d = json.loads(f.read())
+        import platform
+        if platform.system() == 'Darwin':
+            d['handlers']['syslog_handler']['address'] = '/var/run/syslog'
+        logging.config.dictConfig(d)
+        # logging.config.dictConfig(json.loads(f.read()))
diff --git a/inventory_provider/juniper.py b/inventory_provider/juniper.py
index 741ef352381f1b6213e9bfc936fcbe8ac0c3ee61..468eef553af60b98d553a4c9b756b1db5069536f 100644
--- a/inventory_provider/juniper.py
+++ b/inventory_provider/juniper.py
@@ -8,8 +8,6 @@ import netifaces
 import requests
 from requests.auth import HTTPBasicAuth
 
-from inventory_provider.constants import JUNIPER_LOGGER_NAME
-
 CONFIG_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?>
 <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
 
@@ -105,6 +103,11 @@ UNIT_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?>
 </xs:schema>
 """  # noqa: E501
 
+
+# elements 'use-nat' and 'fingerprint' were added between
+# junosspace versions 15.x and 17.x ... hopefully new versions
+# will also add new elements at the end of the sequence so
+# that the final xs:any below will suffice to allow validation
 JUNOSSPACE_DEVICES_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?>
 <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
 
@@ -127,8 +130,7 @@ JUNOSSPACE_DEVICES_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?>
           <xs:element name="domain-id" minOccurs="1" maxOccurs="1" type="xs:string" />
           <xs:element name="domain-name" minOccurs="1" maxOccurs="1" type="xs:string" />
           <xs:element name="config-status" minOccurs="1" maxOccurs="1" type="xs:string" />
-          <xs:element name="use-nat" minOccurs="0" maxOccurs="1" type="xs:boolean" />
-          <xs:element name="fingerprint" minOccurs="0" maxOccurs="1" type="xs:string" />
+          <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" />
     </xs:sequence>
     <xs:attribute name="href" type="xs:string" />
     <xs:attribute name="uri" type="xs:string" />
@@ -159,13 +161,13 @@ def _rpc(hostname, ssh):
 
 
 def validate_netconf_config(config_doc):
-    juniper_logger = logging.getLogger(JUNIPER_LOGGER_NAME)
+    logger = logging.getLogger(__name__)
 
     def _validate(schema, doc):
         if schema.validate(doc):
             return
         for e in schema.error_log:
-            juniper_logger.error("%d.%d: %s" % (e.line, e.column, e.message))
+            logger.error("%d.%d: %s" % (e.line, e.column, e.message))
         assert False
 
     schema_doc = etree.XML(CONFIG_SCHEMA.encode('utf-8'))
@@ -190,8 +192,8 @@ def load_config(hostname, ssh_params):
     :param ssh_params: 'ssh' config element(cf. config.py:CONFIG_SCHEMA)
     :return:
     """
-    juniper_logger = logging.getLogger(JUNIPER_LOGGER_NAME)
-    juniper_logger.info("capturing netconf data for '%s'" % hostname)
+    logger = logging.getLogger(__name__)
+    logger.info("capturing netconf data for '%s'" % hostname)
     config = _rpc(hostname, ssh_params).get_config()
     validate_netconf_config(config)
     return config
@@ -290,6 +292,22 @@ def vpn_rr_peers(netconf_config):
             neighbor['peer-as'] = int(r.find('peer-as').text)
         yield neighbor
 
+
+def interface_addresses(netconf_config):
+    """
+    yields a list of all distinct interface addresses
+    :param netconf_config:
+    :return:
+    """
+    for ifc in list_interfaces(netconf_config):
+        for address in ifc['ipv4'] + ifc['ipv6']:
+            yield {
+                "name": ipaddress.ip_interface(address).ip.exploded,
+                "interface address": address,
+                "interface name": ifc['name']
+            }
+
+
 # note for enabling vrr data parsing ...
 # def fetch_vrr_config(hostname, ssh_params):
 #
@@ -313,7 +331,7 @@ def load_routers_from_junosspace(config):
     :param config: junosspace config element from app config
     :return: list of dictionaries, each element of which describes a router
     """
-    juniper_logger = logging.getLogger(JUNIPER_LOGGER_NAME)
+    logger = logging.getLogger(__name__)
 
     request_url = config['api']
     if not request_url.endswith('/'):
@@ -328,7 +346,7 @@ def load_routers_from_junosspace(config):
     )
     # TODO: use a proper exception type
     if r.status_code != 200:
-        juniper_logger.error("error response from %r" % request_url)
+        logger.error("error response from %r" % request_url)
         assert False  # TODO: use proper exception type
 
     devices = etree.fromstring(r.text.encode('utf-8'))
@@ -336,7 +354,7 @@ def load_routers_from_junosspace(config):
     schema = etree.XMLSchema(schema_doc)
     if not schema.validate(devices):
         for e in schema.error_log:
-            juniper_logger.error("%d.%d: %s" % (e.line, e.column, e.message))
+            logger.error('%d.%d: %s' % (e.line, e.column, e.message))
         assert False
 
     for d in devices.xpath('//devices/device'):
@@ -346,6 +364,8 @@ def load_routers_from_junosspace(config):
         if m:
             hostname = m.group(1) + '.geant.net'
         else:
+            logger.error(
+                'unrecognized junosspace device name format :%s' % name)
             hostname = None
         yield {
             "OSVersion": d.xpath('./OSVersion/text()')[0],
@@ -395,3 +415,17 @@ def snmp_community_string(netconf_config):
                 if me in allowed_network:
                     return community.xpath('./name/text()')[0]
     return None
+
+
+def netconf_changed_timestamp(netconf_config):
+    '''
+    return the last change timestamp published by the config document
+    :param netconf_config: netconf lxml etree document
+    :return: an epoch timestamp (integer number of seconds) or None
+    '''
+    for ts in netconf_config.xpath('/configuration/@changed-seconds'):
+        if re.match(r'^\d+$', ts):
+            return int(ts)
+    logger = logging.getLogger(__name__)
+    logger.warning('no valid timestamp found in netconf configuration')
+    return None
diff --git a/inventory_provider/logging_default_config.json b/inventory_provider/logging_default_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..ba3f1eb38510e597063d1d27ceddbea4c0286f10
--- /dev/null
+++ b/inventory_provider/logging_default_config.json
@@ -0,0 +1,62 @@
+{
+    "version": 1,
+    "disable_existing_loggers": false,
+    "formatters": {
+        "simple": {
+            "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+        }
+    },
+
+    "handlers": {
+        "console": {
+            "class": "logging.StreamHandler",
+            "level": "DEBUG",
+            "formatter": "simple",
+            "stream": "ext://sys.stdout"
+        },
+
+        "syslog_handler": {
+            "class": "logging.handlers.SysLogHandler",
+            "level": "DEBUG",
+            "address": "/dev/log",
+            "facility": "user",
+            "formatter": "simple"
+        },
+
+        "info_file_handler": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "level": "INFO",
+            "formatter": "simple",
+            "filename": "info.log",
+            "maxBytes": 10485760,
+            "backupCount": 20,
+            "encoding": "utf8"
+        },
+
+        "error_file_handler": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "level": "ERROR",
+            "formatter": "simple",
+            "filename": "errors.log",
+            "maxBytes": 10485760,
+            "backupCount": 20,
+            "encoding": "utf8"
+        }
+    },
+
+    "loggers": {
+        "inventory_provider": {
+            "level": "INFO",
+            "handlers": ["console", "syslog_handler"],
+            "propagate": false
+        },
+        "inventory_provider.tasks": {
+            "level": "DEBUG"
+        }
+    },
+
+    "root": {
+        "level": "WARNING",
+        "handlers": ["console", "syslog_handler"]
+    }
+}
\ No newline at end of file
diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py
index 43c8b35a4fd931aa48b6bdedb73aded5c601bf0e..0a9602b8a92c737005567918e1bb9983f09b166b 100644
--- a/inventory_provider/routes/classifier.py
+++ b/inventory_provider/routes/classifier.py
@@ -1,42 +1,12 @@
 import json
 
-from flask import Blueprint, Response, current_app, jsonify
+from flask import Blueprint, Response
 
 from inventory_provider.routes import common
 
 routes = Blueprint("inventory-data-classifier-support-routes", __name__)
 
 
-@routes.route("/infinera-dna-addresses", methods=['GET', 'POST'])
-@common.require_accepts_json
-def infinera_addresses():
-    infinera_config = current_app.config[
-        "INVENTORY_PROVIDER_CONFIG"]["infinera-dna"]
-    return jsonify([dna['address'] for dna in infinera_config])
-
-
-@routes.route("/coriant-tnms-addresses", methods=['GET', 'POST'])
-@common.require_accepts_json
-def coriant_addresses():
-    coriant_config = current_app.config[
-        "INVENTORY_PROVIDER_CONFIG"]["coriant-tnms"]
-    return jsonify([tnms['address'] for tnms in coriant_config])
-
-
-@routes.route("/juniper-server-addresses", methods=['GET', 'POST'])
-@common.require_accepts_json
-def juniper_addresses():
-    # TODO: this route (and corant, infinera routes) can be removed
-    r = common.get_redis()
-    routers = []
-    for k in r.keys('junosspace:*'):
-        info = r.get(k.decode('utf-8'))
-        assert info  # sanity: value shouldn't be empty
-        info = json.loads(info.decode('utf-8'))
-        routers.append(info['address'])
-    return jsonify(routers)
-
-
 @routes.route("/trap-metadata/<source_equipment>/<path:interface>",
               methods=['GET', 'POST'])
 @common.require_accepts_json
diff --git a/inventory_provider/routes/jobs.py b/inventory_provider/routes/jobs.py
index c26ab6a588041272a855065d38d13e1d028b4474..02e778ab4b0bd8d4b85fc44ae79dd33c663701f8 100644
--- a/inventory_provider/routes/jobs.py
+++ b/inventory_provider/routes/jobs.py
@@ -1,23 +1,32 @@
-from flask import Blueprint, Response, current_app
+from flask import Blueprint, Response, current_app, jsonify
 from inventory_provider.tasks import worker
+from inventory_provider.routes import common
 
 routes = Blueprint("inventory-data-job-routes", __name__)
 
 
 @routes.route("/update", methods=['GET', 'POST'])
+@common.require_accepts_json
 def update():
-    worker.start_refresh_cache_all(
+    job_ids = worker.launch_refresh_cache_all(
         current_app.config["INVENTORY_PROVIDER_CONFIG"])
-    return Response("OK")
+    return jsonify(job_ids)
 
 
-@routes.route("update-interface-statuses")
+@routes.route("update-interface-statuses", methods=['GET', 'POST'])
 def update_interface_statuses():
     worker.update_interface_statuses.delay()
     return Response("OK")
 
 
-@routes.route("reload-router-config/<equipment_name>")
+@routes.route("reload-router-config/<equipment_name>", methods=['GET', 'POST'])
+@common.require_accepts_json
 def reload_router_config(equipment_name):
-    worker.reload_router_config.delay(equipment_name)
-    return Response("OK")
+    result = worker.reload_router_config.delay(equipment_name)
+    return jsonify([result.id])
+
+
+@routes.route("check-task-status/<task_id>", methods=['GET', 'POST'])
+@common.require_accepts_json
+def check_task_status(task_id):
+    return jsonify(worker.check_task_status(task_id))
diff --git a/inventory_provider/routes/opsdb.py b/inventory_provider/routes/testing.py
similarity index 54%
rename from inventory_provider/routes/opsdb.py
rename to inventory_provider/routes/testing.py
index 4b7fba38a3ea89d7b03933ded6f5b145dab978c9..8d66aa5016c124ed9238a957116ac2a58c79cc4f 100644
--- a/inventory_provider/routes/opsdb.py
+++ b/inventory_provider/routes/testing.py
@@ -2,23 +2,49 @@ import collections
 import json
 import re
 
-from flask import Blueprint, jsonify
+from flask import Blueprint, Response, current_app, jsonify
 from inventory_provider.routes import common
 
-routes = Blueprint("inventory-opsdb-query-routes", __name__)
+routes = Blueprint("inventory-data-testing-support-routes", __name__)
 
-interfaces_key = "interface_services"
-equipment_locations_key = "equipment_locations"
-service_child_to_parents_key = "child_to_parent_circuit_relations"
-service_parent_to_children_key = "parent_to_children_circuit_relations"
-interface_status_key = "interface_statuses"
 
+@routes.route("flushdb", methods=['GET', 'POST'])
+def flushdb():
+    common.get_redis().flushdb()
+    return Response('OK')
 
-# def _decode_utf8_dict(d):
-#     return {k.decode('utf8'): json.loads(v) for k, v in d.items()}
-#
-#
-@routes.route("/interfaces")
+
+@routes.route("infinera-dna-addresses", methods=['GET', 'POST'])
+@common.require_accepts_json
+def infinera_addresses():
+    infinera_config = current_app.config[
+        "INVENTORY_PROVIDER_CONFIG"]["infinera-dna"]
+    return jsonify([dna['address'] for dna in infinera_config])
+
+
+@routes.route("coriant-tnms-addresses", methods=['GET', 'POST'])
+@common.require_accepts_json
+def coriant_addresses():
+    coriant_config = current_app.config[
+        "INVENTORY_PROVIDER_CONFIG"]["coriant-tnms"]
+    return jsonify([tnms['address'] for tnms in coriant_config])
+
+
+@routes.route("juniper-server-addresses", methods=['GET', 'POST'])
+@common.require_accepts_json
+def juniper_addresses():
+    # TODO: this route (and corant, infinera routes) can be removed
+    r = common.get_redis()
+    routers = []
+    for k in r.keys('junosspace:*'):
+        info = r.get(k.decode('utf-8'))
+        assert info  # sanity: value shouldn't be empty
+        info = json.loads(info.decode('utf-8'))
+        routers.append(info['address'])
+    return jsonify(routers)
+
+
+@routes.route("opsdb/interfaces")
 def get_all_interface_details():
     r = common.get_redis()
     result = collections.defaultdict(list)
@@ -31,7 +57,7 @@ def get_all_interface_details():
     return jsonify(result)
 
 
-@routes.route("/interfaces/<equipment_name>")
+@routes.route("opsdb/interfaces/<equipment_name>")
 def get_interface_details_for_equipment(equipment_name):
     r = common.get_redis()
     result = []
@@ -44,7 +70,7 @@ def get_interface_details_for_equipment(equipment_name):
     return jsonify(result)
 
 
-@routes.route("/interfaces/<equipment_name>/<path:interface>")
+@routes.route("opsdb/interfaces/<equipment_name>/<path:interface>")
 def get_interface_details(equipment_name, interface):
     r = common.get_redis()
     key = 'opsdb:interface_services:%s:%s' % (equipment_name, interface)
@@ -52,7 +78,7 @@ def get_interface_details(equipment_name, interface):
     return jsonify(json.loads(r.get(key).decode('utf-8')))
 
 
-@routes.route("/equipment-location")
+@routes.route("opsdb/equipment-location")
 def get_all_equipment_locations():
     r = common.get_redis()
     result = {}
@@ -64,7 +90,7 @@ def get_all_equipment_locations():
     return jsonify(result)
 
 
-@routes.route("/equipment-location/<path:equipment_name>")
+@routes.route("opsdb/equipment-location/<path:equipment_name>")
 def get_equipment_location(equipment_name):
     r = common.get_redis()
     result = r.get('opsdb:location:' + equipment_name)
@@ -72,7 +98,7 @@ def get_equipment_location(equipment_name):
     return jsonify(json.loads(result.decode('utf-8')))
 
 
-@routes.route("/circuit-hierarchy/children/<int:parent_id>")
+@routes.route("opsdb/circuit-hierarchy/children/<int:parent_id>")
 def get_children(parent_id):
     r = common.get_redis()
     result = r.get('opsdb:services:children:%d' % parent_id)
@@ -80,7 +106,7 @@ def get_children(parent_id):
     return jsonify(json.loads(result.decode('utf-8')))
 
 
-@routes.route("/circuit-hierarchy/parents/<int:child_id>")
+@routes.route("opsdb/circuit-hierarchy/parents/<int:child_id>")
 def get_parents(child_id):
     r = common.get_redis()
     result = r.get('opsdb:services:parents:%d' % child_id)
diff --git a/inventory_provider/snmp.py b/inventory_provider/snmp.py
index a97df15d0b5aed2899cbdda0489a736d5f40153a..a5a48b7067e75117756d779c87abdd1767718c48 100644
--- a/inventory_provider/snmp.py
+++ b/inventory_provider/snmp.py
@@ -6,8 +6,6 @@ from pysnmp.hlapi import nextCmd, SnmpEngine, CommunityData, \
 from pysnmp.smi import builder, compiler
 # from pysnmp.smi import view, rfc1902
 
-from inventory_provider.constants import SNMP_LOGGER_NAME
-
 
 def _v6address_oid2str(dotted_decimal):
     hex_params = []
@@ -30,7 +28,7 @@ def walk(agent_hostname, community, base_oid):  # pragma: no cover
     :return:
     """
 
-    snmp_logger = logging.getLogger(SNMP_LOGGER_NAME)
+    logger = logging.getLogger(__name__)
 
     mibBuilder = builder.MibBuilder()
     # mibViewController = view.MibViewController(mibBuilder)
@@ -43,7 +41,7 @@ def walk(agent_hostname, community, base_oid):  # pragma: no cover
         'SNMP-COMMUNITY-MIB',
         'RFC1213-MIB')
 
-    snmp_logger.debug("walking %s: %s" % (agent_hostname, base_oid))
+    logger.debug("walking %s: %s" % (agent_hostname, base_oid))
 
     for (engineErrorIndication,
          pduErrorIndication,
@@ -57,16 +55,26 @@ def walk(agent_hostname, community, base_oid):  # pragma: no cover
             lexicographicMode=False,
             lookupNames=True,
             lookupValues=True):
-        assert not engineErrorIndication
-        assert not pduErrorIndication
-        assert errorIndex == 0
+
+        # cf. http://snmplabs.com/
+        #       pysnmp/examples/hlapi/asyncore/sync/contents.html
+        assert not engineErrorIndication, (
+            'snmp response engine error indication: %r'
+            % str(engineErrorIndication))
+        assert not pduErrorIndication, 'snmp response pdu error %r at %r' % (
+            pduErrorIndication,
+            errorIndex and varBinds[int(errorIndex) - 1][0] or '?')
+        assert errorIndex == 0, (
+            'sanity failure: errorIndex != 0, '
+            'but no error indication')
+
         # varBinds = [
         #     rfc1902.ObjectType(rfc1902.ObjectIdentity(x[0]),x[1])
         #         .resolveWithMib(mibViewController)
         #     for x in varBinds]
         for oid, val in varBinds:
             result = {"oid": "." + str(oid), "value": val.prettyPrint()}
-            snmp_logger.debug(result)
+            logger.debug(result)
             yield result
 
 
diff --git a/inventory_provider/tasks/config.py b/inventory_provider/tasks/config.py
index 84d4878ed1377547e9164b3764ef21f10239dd36..9e59a19335b81715406ef31c9acec14e81158909 100644
--- a/inventory_provider/tasks/config.py
+++ b/inventory_provider/tasks/config.py
@@ -6,3 +6,4 @@ broker_url = getenv(
 result_backend = getenv(
     'CELERY_BROKER_URL',
     default='redis://test-dashboard02.geant.org:6379/1')
+task_eager_propagates = True
diff --git a/inventory_provider/tasks/worker.py b/inventory_provider/tasks/worker.py
index cdfc648fff7e5d261919f4fdda0a99b4420eb4ae..075660cbecc731b4e5cda344d437a9ebaa7c01e2 100644
--- a/inventory_provider/tasks/worker.py
+++ b/inventory_provider/tasks/worker.py
@@ -2,7 +2,8 @@ import json
 import logging
 import re
 
-from celery import bootsteps, Task, group
+from celery import bootsteps, Task, group, states
+from celery.result import AsyncResult
 
 from collections import defaultdict
 from lxml import etree
@@ -10,7 +11,6 @@ from lxml import etree
 from inventory_provider.tasks.app import app
 from inventory_provider.tasks.common import get_redis
 from inventory_provider import config
-from inventory_provider import constants
 from inventory_provider import environment
 from inventory_provider.db import db, opsdb, alarmsdb
 from inventory_provider import snmp
@@ -21,52 +21,52 @@ from inventory_provider import juniper
 environment.setup_logging()
 
 
+class InventoryTaskError(Exception):
+    pass
+
+
 class InventoryTask(Task):
 
     config = None
-    # logger = None
 
     def __init__(self):
         pass
 
-    # @staticmethod
-    # def save_key(hostname, key, value):
-    #     assert isinstance(value, str), \
-    #         "sanity failure: expected string data as value"
-    #     r = get_redis(InventoryTask.config)
-    #     r.hset(
-    #         name=hostname,
-    #         key=key,
-    #         value=value)
-    #     InventoryTask.logger.debug(
-    #         "saved %s, key %s" % (hostname, key))
-    #     return "OK"
-
-    @staticmethod
-    def save_value(key, value):
-        assert isinstance(value, str), \
-            "sanity failure: expected string data as value"
-        r = get_redis(InventoryTask.config)
-        r.set(name=key, value=value)
-        # InventoryTask.logger.debug("saved %s" % key)
-        return "OK"
+    def update_state(self, **kwargs):
+        logger = logging.getLogger(__name__)
+        logger.debug(json.dumps(
+            {'state': kwargs['state'], 'meta': kwargs['meta']}
+        ))
+        super().update_state(**kwargs)
+
+
+def _save_value(key, value):
+    assert isinstance(value, str), \
+        "sanity failure: expected string data as value"
+    r = get_redis(InventoryTask.config)
+    r.set(name=key, value=value)
+    # InventoryTask.logger.debug("saved %s" % key)
+    return "OK"
+
 
-    @staticmethod
-    def save_value_json(key, data_obj):
-        InventoryTask.save_value(
-            key,
-            json.dumps(data_obj))
+def _save_value_json(key, data_obj):
+    _save_value(
+        key,
+        json.dumps(data_obj))
 
-    @staticmethod
-    def save_value_etree(key, xml_doc):
-        InventoryTask.save_value(
-            key,
-            etree.tostring(xml_doc, encoding='unicode'))
+
+def _save_value_etree(key, xml_doc):
+    _save_value(
+        key,
+        etree.tostring(xml_doc, encoding='unicode'))
 
 
 class WorkerArgs(bootsteps.Step):
     def __init__(self, worker, config_filename, **options):
         with open(config_filename) as f:
+            logger = logging.getLogger(__name__)
+            logger.info(
+                "Initializing worker with config from: %r" % config_filename)
             InventoryTask.config = config.load(f)
 
 
@@ -85,37 +85,37 @@ app.steps['worker'].add(WorkerArgs)
 
 @app.task
 def snmp_refresh_interfaces(hostname, community):
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug(
+    logger = logging.getLogger(__name__)
+    logger.debug(
         '>>> snmp_refresh_interfaces(%r, %r)' % (hostname, community))
 
-    InventoryTask.save_value_json(
+    _save_value_json(
         'snmp-interfaces:' + hostname,
         list(snmp.get_router_interfaces(
             hostname,
             community,
             InventoryTask.config)))
 
-    task_logger.debug(
+    logger.debug(
         '<<< snmp_refresh_interfaces(%r, %r)' % (hostname, community))
 
 
 @app.task
 def netconf_refresh_config(hostname):
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> netconf_refresh_config(%r)' % hostname)
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> netconf_refresh_config(%r)' % hostname)
 
-    InventoryTask.save_value_etree(
+    _save_value_etree(
         'netconf:' + hostname,
         juniper.load_config(hostname, InventoryTask.config["ssh"]))
 
-    task_logger.debug('<<< netconf_refresh_config(%r)' % hostname)
+    logger.debug('<<< netconf_refresh_config(%r)' % hostname)
 
 
 @app.task
 def update_interfaces_to_services():
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> update_interfaces_to_services')
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> update_interfaces_to_services')
 
     interface_services = defaultdict(list)
     with db.connection(InventoryTask.config["ops-db"]) as cx:
@@ -132,13 +132,13 @@ def update_interfaces_to_services():
             'opsdb:interface_services:' + equipment_interface,
             json.dumps(services))
 
-    task_logger.debug('<<< update_interfaces_to_services')
+    logger.debug('<<< update_interfaces_to_services')
 
 
 @app.task
 def update_equipment_locations():
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> update_equipment_locations')
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> update_equipment_locations')
 
     r = get_redis(InventoryTask.config)
     for key in r.scan_iter('opsdb:location:*'):
@@ -147,13 +147,13 @@ def update_equipment_locations():
         for ld in opsdb.get_equipment_location_data(cx):
             r.set('opsdb:location:%s' % ld['equipment_name'], json.dumps(ld))
 
-    task_logger.debug('<<< update_equipment_locations')
+    logger.debug('<<< update_equipment_locations')
 
 
 @app.task
 def update_circuit_hierarchy():
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> update_circuit_hierarchy')
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> update_circuit_hierarchy')
 
     # TODO: integers are not JSON keys
     with db.connection(InventoryTask.config["ops-db"]) as cx:
@@ -176,13 +176,13 @@ def update_circuit_hierarchy():
         for cid, children in child_to_parents.items():
             r.set('opsdb:services:children:%d' % cid, json.dumps(children))
 
-    task_logger.debug('<<< update_circuit_hierarchy')
+    logger.debug('<<< update_circuit_hierarchy')
 
 
 @app.task
 def update_interface_statuses():
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> update_interface_statuses')
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> update_interface_statuses')
 
     with db.connection(InventoryTask.config["ops-db"]) as cx:
         services = opsdb.get_circuits(cx)
@@ -195,24 +195,48 @@ def update_interface_statuses():
                     csr,
                     service["equipment"],
                     service["interface_name"])
-                InventoryTask.save_value(key, status)
+                _save_value(key, status)
 
-    task_logger.debug('<<< update_interface_statuses')
+    logger.debug('<<< update_interface_statuses')
 
 
-@app.task
-def update_junosspace_device_list():
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> update_junosspace_device_list')
+@app.task(base=InventoryTask, bind=True)
+def update_junosspace_device_list(self):
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> update_junosspace_device_list')
+
+    self.update_state(
+        state=states.STARTED,
+        meta={
+            'task': 'update_junosspace_device_list',
+            'message': 'querying junosspace for managed routers'
+        })
 
     r = get_redis(InventoryTask.config)
+
+    routers = {}
     for d in juniper.load_routers_from_junosspace(
-            InventoryTask.config["junosspace"]):
-        r.set(
-            'junosspace:' + d['hostname'],
-            json.dumps(d).encode('utf-8'))
+            InventoryTask.config['junosspace']):
+        routers['junosspace:' + d['hostname']] = json.dumps(d).encode('utf-8')
+
+    self.update_state(
+        state=states.STARTED,
+        meta={
+            'task': 'update_junosspace_device_list',
+            'message': 'found %d routers, saving details' % len(routers)
+        })
 
-    task_logger.debug('<<< update_junosspace_device_list')
+    for k in r.keys('junosspace:*'):
+        r.delete(k)
+    for k, v in routers.items():
+        r.set(k, v)
+
+    logger.debug('<<< update_junosspace_device_list')
+
+    return {
+        'task': 'update_junosspace_device_list',
+        'message': 'saved %d managed routers' % len(routers)
+    }
 
 
 def load_netconf_data(hostname):
@@ -225,13 +249,13 @@ def load_netconf_data(hostname):
     r = get_redis(InventoryTask.config)
     netconf = r.get('netconf:' + hostname)
     if not netconf:
-        return None
+        raise InventoryTaskError('no netconf data found for %r' % hostname)
     return etree.fromstring(netconf.decode('utf-8'))
 
 
 def clear_cached_classifier_responses(hostname):
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug(
+    logger = logging.getLogger(__name__)
+    logger.debug(
         'removing cached classifier responses for %r' % hostname)
     r = get_redis(InventoryTask.config)
     for k in r.keys('classifier:cache:%s:*' % hostname):
@@ -239,8 +263,8 @@ def clear_cached_classifier_responses(hostname):
 
 
 def _refresh_peers(hostname, key_base, peers):
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug(
+    logger = logging.getLogger(__name__)
+    logger.debug(
         'removing cached %s for %r' % (key_base, hostname))
     r = get_redis(InventoryTask.config)
     for k in r.keys(key_base + ':*'):
@@ -274,32 +298,91 @@ def refresh_vpn_rr_peers(hostname, netconf):
         juniper.vpn_rr_peers(netconf))
 
 
-@app.task
-def reload_router_config(hostname):
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
-    task_logger.debug('>>> update_router_config')
-
+def refresh_interface_address_lookups(hostname, netconf):
+    _refresh_peers(
+        hostname,
+        'reverse_interface_addresses',
+        juniper.interface_addresses(netconf))
+
+
+@app.task(base=InventoryTask, bind=True)
+def reload_router_config(self, hostname):
+    logger = logging.getLogger(__name__)
+    logger.debug('>>> reload_router_config')
+
+    self.update_state(
+        state=states.STARTED,
+        meta={
+            'task': 'reload_router_config',
+            'hostname': hostname,
+            'message': 'loading router netconf data'
+        })
+
+    # get the timestamp for the current netconf data
+    current_netconf_timestamp = None
+    try:
+        netconf_doc = load_netconf_data(hostname)
+        current_netconf_timestamp \
+            = juniper.netconf_changed_timestamp(netconf_doc)
+        logger.debug(
+            'current netconf timestamp: %r' % current_netconf_timestamp)
+    except InventoryTaskError:
+        pass  # ok at this point if not found
+
+    # load new netconf data
     netconf_refresh_config.apply(args=[hostname])
 
     netconf_doc = load_netconf_data(hostname)
-    if netconf_doc is None:
-        task_logger.error('no netconf data available for %r' % hostname)
-    else:
-
-        refresh_ix_public_peers(hostname, netconf_doc)
-        refresh_vpn_rr_peers(hostname, netconf_doc)
 
-        community = juniper.snmp_community_string(netconf_doc)
-        if not community:
-            task_logger.error(
-                'error extracting community string for %r' % hostname)
-        else:
-            snmp_refresh_interfaces.apply(args=[hostname, community])
+    # return if new timestamp is the same as the original timestamp
+    new_netconf_timestamp = juniper.netconf_changed_timestamp(netconf_doc)
+    assert new_netconf_timestamp, \
+        'no timestamp available for new netconf data'
+    if new_netconf_timestamp == current_netconf_timestamp:
+        logger.debug('no netconf change timestamp change, aborting')
+        logger.debug('<<< reload_router_config')
+        return {
+            'task': 'reload_router_config',
+            'hostname': hostname,
+            'message': 'OK (no change)'
+        }
+
+    # clear cached classifier responses for this router, and
+    # refresh peering data
+    self.update_state(
+        state=states.STARTED,
+        meta={
+            'task': 'reload_router_config',
+            'hostname': hostname,
+            'message': 'refreshing peers & clearing cache'
+        })
+    refresh_ix_public_peers(hostname, netconf_doc)
+    refresh_vpn_rr_peers(hostname, netconf_doc)
+    refresh_interface_address_lookups(hostname, netconf_doc)
+    clear_cached_classifier_responses(hostname)
+
+    # load snmp indexes
+    community = juniper.snmp_community_string(netconf_doc)
+    if not community:
+        raise InventoryTaskError(
+            'error extracting community string for %r' % hostname)
+    else:
+        self.update_state(
+            state=states.STARTED,
+            meta={
+                'task': 'reload_router_config',
+                'hostname': hostname,
+                'message': 'refreshing snmp interface indexes'
+            })
+        snmp_refresh_interfaces.apply(args=[hostname, community])
 
-            # TODO: move this out of else? (i.e. clear even if netconf fails?)
-            clear_cached_classifier_responses(hostname)
+    logger.debug('<<< reload_router_config')
 
-    task_logger.debug('<<< update_router_config')
+    return {
+        'task': 'reload_router_config',
+        'hostname': hostname,
+        'message': 'OK'
+    }
 
 
 def _derive_router_hostnames(config):
@@ -320,13 +403,13 @@ def _derive_router_hostnames(config):
     return junosspace_equipment & opsdb_equipment
 
 
-def start_refresh_cache_all(config):
+def launch_refresh_cache_all(config):
     """
     utility function intended to be called outside of the worker process
     :param config: config structure as defined in config.py
     :return:
     """
-    task_logger = logging.getLogger(constants.TASK_LOGGER_NAME)
+    logger = logging.getLogger(__name__)
 
     # first batch of subtasks: refresh cached opsdb data
     subtasks = [
@@ -346,8 +429,30 @@ def start_refresh_cache_all(config):
         update_interface_statuses.s()
     ]
     for hostname in _derive_router_hostnames(config):
-        task_logger.debug(
+        logger.debug(
             'queueing router refresh jobs for %r' % hostname)
         subtasks.append(reload_router_config.s(hostname))
 
-    return group(subtasks).apply_async()
+    return [r.id for r in group(subtasks).apply_async()]
+
+
+def check_task_status(task_id):
+    r = AsyncResult(task_id, app=app)
+    result = {
+        'id': r.id,
+        'status': r.status,
+        'exception': r.status in states.EXCEPTION_STATES,
+        'ready': r.status in states.READY_STATES,
+        'success': r.status == states.SUCCESS,
+    }
+    if r.result:
+        # TODO: only discovered this case by testing, is this the only one?
+        #       ... otherwise need to pre-test json serialization
+        if isinstance(r.result, Exception):
+            result['result'] = {
+                'error type': type(r.result).__name__,
+                'message': str(r.result)
+            }
+        else:
+            result['result'] = r.result
+    return result
diff --git a/test/conftest.py b/test/conftest.py
index ca7000226686d4739bf587978e1500d831406c6b..0a9a3cec059287e5068958351e8d21a81a578808 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -115,6 +115,10 @@ class MockedRedis(object):
             k.encode("utf-8") for k in MockedRedis.db.keys()
             if k.startswith(m.group(1))])
 
+    def flushdb(self):
+        # only called from testing routes (hopefully)
+        pass
+
 
 @pytest.fixture
 def data_config():
@@ -137,6 +141,7 @@ def app_config():
             f.write("%s = '%s'\n" % (
                 "INVENTORY_PROVIDER_CONFIG_FILENAME",
                 data_config_filename(tmpdir)))
+            f.write('ENABLE_TESTING_ROUTES = True\n')
 
         yield app_config_filename
 
diff --git a/test/per_router/test_celery_worker.py b/test/per_router/test_celery_worker.py
index 81ad1be8b87c8637d70694c4464239229e9d855d..fa8037043e5e8b3ba3e85ae2489c0f0f14bcb0d9 100644
--- a/test/per_router/test_celery_worker.py
+++ b/test/per_router/test_celery_worker.py
@@ -133,9 +133,11 @@ def test_reload_router_config(mocked_worker_module, router, mocker):
         'inventory_provider.tasks.worker.snmp_refresh_interfaces.apply',
         _mocked_snmp_refresh_interfaces_apply)
 
+    def _mocked_update_status(self, **kwargs):
+        pass
     mocker.patch(
-        'inventory_provider.tasks.worker.snmp_refresh_interfaces.apply',
-        _mocked_snmp_refresh_interfaces_apply)
+        'inventory_provider.tasks.worker.InventoryTask.update_state',
+        _mocked_update_status)
 
     worker.reload_router_config(router)
     assert 'netconf:' + router in MockedRedis.db
diff --git a/test/per_router/test_juniper_data.py b/test/per_router/test_juniper_data.py
index 2574209e2f74c9ea19ee67dab924c7415d415ce1..721477640e199595517261c8107d954c39160de5 100644
--- a/test/per_router/test_juniper_data.py
+++ b/test/per_router/test_juniper_data.py
@@ -102,3 +102,53 @@ def test_bgp_list(netconf_doc):
 
 def test_snmp_community_string(mocked_netifaces, netconf_doc):
     assert juniper.snmp_community_string(netconf_doc) == '0pBiFbD'
+
+
+def test_interface_addresses_list(netconf_doc):
+    schema = {
+        "$schema": "http://json-schema.org/draft-07/schema#",
+
+        "definitions": {
+            "v4a": {
+                "type": "string",
+                "pattern": r'^(\d+\.){3}\d+$'
+            },
+            "v6a": {
+                "type": "string",
+                "pattern": r'^([a-f\d]{4}:){7}[a-f\d]{4}$'
+            },
+            "v4i": {
+                "type": "string",
+                "pattern": r'^(\d+\.){3}\d+/\d+$'
+            },
+            "v6i": {
+                "type": "string",
+                "pattern": r'^[a-f\d:]+/\d+$'
+            }
+        },
+
+        "type": "array",
+        "items": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "oneOf": [
+                        {"$ref": "#/definitions/v4a"},
+                        {"$ref": "#/definitions/v6a"}
+                    ]
+                },
+                "interface address": {
+                    "oneOf": [
+                        {"$ref": "#/definitions/v4i"},
+                        {"$ref": "#/definitions/v6i"}
+                    ]
+                },
+                "interface name": {"type": "string"},
+            },
+            "required": ["name", "interface address", "interface name"],
+            "additionalProperties": False
+        }
+    }
+
+    addresses = list(juniper.interface_addresses(netconf_doc))
+    jsonschema.validate(addresses, schema)
diff --git a/test/test_classifier_routes.py b/test/test_classifier_routes.py
index 7ea6f6c810f7ac638334f239423f6972123caba5..6b68221d42e1805c373990ea12cbdc8950bb57db 100644
--- a/test/test_classifier_routes.py
+++ b/test/test_classifier_routes.py
@@ -7,55 +7,6 @@ DEFAULT_REQUEST_HEADERS = {
 }
 
 
-def test_infinera_addresses(client):
-    response_schema = {
-        "$schema": "http://json-schema.org/draft-07/schema#",
-        "type": "array",
-        "items": {"type": "string"}
-    }
-
-    rv = client.post(
-        "/classifier/infinera-dna-addresses",
-        headers=DEFAULT_REQUEST_HEADERS)
-    assert rv.status_code == 200
-    jsonschema.validate(
-        json.loads(rv.data.decode("utf-8")),
-        response_schema)
-
-
-def test_coriant_addresses(client):
-    response_schema = {
-        "$schema": "http://json-schema.org/draft-07/schema#",
-        "type": "array",
-        "items": {"type": "string"}
-    }
-
-    rv = client.post(
-        "/classifier/coriant-tnms-addresses",
-        headers=DEFAULT_REQUEST_HEADERS)
-    assert rv.status_code == 200
-    jsonschema.validate(
-        json.loads(rv.data.decode("utf-8")),
-        response_schema)
-
-
-def test_juniper_addresses(mocker, client):
-
-    response_schema = {
-            "$schema": "http://json-schema.org/draft-07/schema#",
-            "type": "array",
-            "items": {"type": "string"}
-        }
-
-    rv = client.post(
-        "/classifier/juniper-server-addresses",
-        headers=DEFAULT_REQUEST_HEADERS)
-    assert rv.status_code == 200
-    response_data = json.loads(rv.data.decode('utf-8'))
-    jsonschema.validate(response_data, response_schema)
-    assert len(response_data) > 0  # test data is not empty
-
-
 def test_trap_metadata(client_with_mocked_data):
     response_schema = {
         "$schema": "http://json-schema.org/draft-07/schema#",
diff --git a/test/test_job_routes.py b/test/test_job_routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..427ceafd39d521d88eb848b450add5258745ba21
--- /dev/null
+++ b/test/test_job_routes.py
@@ -0,0 +1,125 @@
+import json
+import jsonschema
+
+DEFAULT_REQUEST_HEADERS = {
+    "Content-type": "application/json",
+    "Accept": ["application/json"]
+}
+
+TASK_ID_LIST_SCHEMA = {
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "type": "array",
+    "items": {"type": "string"}
+}
+
+TASK_STATUS_SCHEMA = {
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "type": "object",
+    "properties": {
+        "id": {"type": "string"},
+        "status": {"type": "string"},
+        "exception": {"type": "boolean"},
+        "ready": {"type": "boolean"},
+        "success": {"type": "boolean"},
+        "result": {"type": "object"}
+    },
+    "required": ["id", "status", "exception", "ready", "success"],
+    "additionalProperties": False
+}
+
+
+def test_job_update_all(client, mocker):
+    launch_refresh_cache_all = mocker.patch(
+        'inventory_provider.tasks.worker.launch_refresh_cache_all')
+    launch_refresh_cache_all.return_value = ['abc', 'def', 'xyz@123#456']
+
+    rv = client.post(
+        'jobs/update',
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    task_id_list = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(task_id_list, TASK_ID_LIST_SCHEMA)
+    assert len(task_id_list) == 3
+
+
+class MockedAsyncResult(object):
+    status = None
+    result = None
+
+    def __init__(self, id, app=None):
+        self.id = id
+
+
+def test_reload_router_config(client, mocker):
+    delay_result = mocker.patch(
+        'inventory_provider.tasks.worker.reload_router_config.delay')
+    delay_result.return_value = MockedAsyncResult('bogus task id')
+
+    rv = client.post(
+        'jobs/reload-router-config/ignored###123',
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    task_id_list = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(task_id_list, TASK_ID_LIST_SCHEMA)
+    assert task_id_list == ['bogus task id']
+
+
+def test_check_task_status_success(client, mocker):
+    mocker.patch(
+        'inventory_provider.tasks.worker.AsyncResult', MockedAsyncResult)
+    MockedAsyncResult.status = 'SUCCESS'  # celery.states.SUCCESS
+    MockedAsyncResult.result = {'abc': 1, 'def': 'aaabbb'}
+
+    rv = client.post(
+        'jobs/check-task-status/abc',
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    status = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(status, TASK_STATUS_SCHEMA)
+    assert status['id'] == 'abc'
+    assert status['status'] == 'SUCCESS'
+    assert not status['exception']
+    assert status['ready']
+    assert status['success']
+    assert 'result' in status
+
+
+def test_check_task_status_custom_status(client, mocker):
+    mocker.patch(
+        'inventory_provider.tasks.worker.AsyncResult', MockedAsyncResult)
+    MockedAsyncResult.status = 'custom'
+    MockedAsyncResult.result = None
+
+    rv = client.post(
+        'jobs/check-task-status/xyz',
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    status = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(status, TASK_STATUS_SCHEMA)
+    assert status['id'] == 'xyz'
+    assert status['status'] == 'custom'
+    assert not status['exception']
+    assert not status['ready']
+    assert not status['success']
+    assert 'result' not in status
+
+
+def test_check_task_status_exception(client, mocker):
+    mocker.patch(
+        'inventory_provider.tasks.worker.AsyncResult', MockedAsyncResult)
+    MockedAsyncResult.status = 'FAILURE'  # celery.states.FAILURE
+    MockedAsyncResult.result = AssertionError('test error message')
+
+    rv = client.post(
+        'jobs/check-task-status/123-xyz.ABC',
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    status = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(status, TASK_STATUS_SCHEMA)
+    assert status['id'] == '123-xyz.ABC'
+    assert status['status'] == 'FAILURE'
+    assert status['exception']
+    assert status['ready']
+    assert not status['success']
+    assert status['result']['error type'] == 'AssertionError'
+    assert status['result']['message'] == 'test error message'
diff --git a/test/test_snmp_handling.py b/test/test_snmp_handling.py
index f8138a68e6a090befa102cf3ab50eed7b656f325..08485456e629bc31821555240bf4c6d97137493f 100644
--- a/test/test_snmp_handling.py
+++ b/test/test_snmp_handling.py
@@ -26,20 +26,58 @@ def test_snmp_interfaces(mocker, data_config, snmp_walk_responses):
 
     expected_result_schema = {
         "$schema": "http://json-schema.org/draft-07/schema#",
+
+        "definitions": {
+            "v4ifc": {
+                "type": "object",
+                "properties": {
+                    "v4Address": {
+                        "type": "string",
+                        "pattern": r'^(\d+\.){3}\d+$'
+                    },
+                    "v4Mask": {
+                        "type": "string",
+                        "pattern": r'^(\d+\.){3}\d+$'
+                    },
+                    "v4InterfaceName": {"type": "string"},
+                    "index": {
+                        "type": "string",
+                        "pattern": r'^\d+$'
+                    }
+                },
+                "required": [
+                    "v4Address", "v4Mask", "v4InterfaceName", "index"],
+                "additionalProperties": False
+            },
+            "v6ifc": {
+                "type": "object",
+                "properties": {
+                    "v6Address": {
+                        "type": "string",
+                        "pattern": r'^[a-f\d:]+$'
+                    },
+                    "v6Mask": {
+                        "type": "string",
+                        "pattern": r'^\d+$'
+                    },
+                    "v6InterfaceName": {"type": "string"},
+                    "index": {
+                        "type": "string",
+                        "pattern": r'^\d+$'
+                    }
+                },
+                "required": [
+                    "v6Address", "v6Mask", "v6InterfaceName", "index"],
+                "additionalProperties": False
+            }
+        },
+
         "type": "array",
         "items": {
-            "type": "object",
-            "properties": {
-                "v4Address": {"type": "string"},
-                "v4Mask": {"type": "string"},
-                "v4InterfaceName": {"type": "string"},
-                "v6Address": {"type": "string"},
-                "v6Mask": {"type": "string"},
-                "v6InterfaceName": {"type": "string"},
-                "index": {"type": "string"}
-            },
-            "required": ["index"],
-            "additionalProperties": False
+            "anyOf": [
+                {"$ref": "#/definitions/v4ifc"},
+                {"$ref": "#/definitions/v6ifc"}
+            ]
         }
     }
 
@@ -57,13 +95,3 @@ def test_snmp_interfaces(mocker, data_config, snmp_walk_responses):
 
     jsonschema.validate(interfaces, expected_result_schema)
     assert interfaces, "interface list isn't empty"
-    for ifc in interfaces:
-        if 'v4Address' in ifc \
-                and 'v4Mask' in ifc \
-                and 'v4InterfaceName' in ifc:
-            continue
-        if 'v6Address' in ifc \
-                and 'v6Mask' in ifc \
-                and 'v6InterfaceName' in ifc:
-            continue
-        assert False, "address details not found in interface dict"
diff --git a/test/test_external_inventory_routes.py b/test/test_testing_routes.py
similarity index 50%
rename from test/test_external_inventory_routes.py
rename to test/test_testing_routes.py
index 94687251b8fc35075d8a8763a47d9cd69bc4dc87..2ab4bd1930514a772f5d7b5f97b265dbe4c3f7ee 100644
--- a/test/test_external_inventory_routes.py
+++ b/test/test_testing_routes.py
@@ -1,13 +1,56 @@
+import json
+import jsonschema
 
 DEFAULT_REQUEST_HEADERS = {
     "Content-type": "application/json",
     "Accept": ["application/json"]
 }
 
+ROUTER_LIST_SCHEMA = {
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "type": "array",
+    "items": {"type": "string"}
+}
+
+
+def test_flushdb(client):
+    rv = client.post("/testing/flushdb")
+    assert rv.status_code == 200
+
+
+def test_infinera_addresses(client):
+    rv = client.post(
+        "/testing/infinera-dna-addresses",
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    jsonschema.validate(
+        json.loads(rv.data.decode("utf-8")),
+        ROUTER_LIST_SCHEMA)
+
+
+def test_coriant_addresses(client):
+    rv = client.post(
+        "/testing/coriant-tnms-addresses",
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    jsonschema.validate(
+        json.loads(rv.data.decode("utf-8")),
+        ROUTER_LIST_SCHEMA)
+
+
+def test_juniper_addresses(client):
+    rv = client.post(
+        "/testing/juniper-server-addresses",
+        headers=DEFAULT_REQUEST_HEADERS)
+    assert rv.status_code == 200
+    response_data = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(response_data, ROUTER_LIST_SCHEMA)
+    assert len(response_data) > 0  # test data is not empty
+
 
 def test_get_equipment_location(client_with_mocked_data):
     rv = client_with_mocked_data.get(
-        '/opsdb/equipment-location',
+        '/testing/opsdb/equipment-location',
         headers=DEFAULT_REQUEST_HEADERS)
     assert rv.status_code == 200
     assert rv.is_json
@@ -16,7 +59,7 @@ def test_get_equipment_location(client_with_mocked_data):
 
 def test_get_interface_info(client_with_mocked_data):
     rv = client_with_mocked_data.get(
-        '/opsdb/interfaces',
+        '/testing/opsdb/interfaces',
         headers=DEFAULT_REQUEST_HEADERS)
     assert rv.status_code == 200
     assert rv.is_json
@@ -25,7 +68,7 @@ def test_get_interface_info(client_with_mocked_data):
 
 def test_get_interface_info_for_equipment(client_with_mocked_data):
     rv = client_with_mocked_data.get(
-        '/opsdb/interfaces/mx1.ams.nl.geant.net',
+        '/testing/opsdb/interfaces/mx1.ams.nl.geant.net',
         headers=DEFAULT_REQUEST_HEADERS)
     assert rv.status_code == 200
     assert rv.is_json
@@ -35,7 +78,7 @@ def test_get_interface_info_for_equipment(client_with_mocked_data):
 def test_get_interface_info_for_equipment_and_interface(
             client_with_mocked_data):
     rv = client_with_mocked_data.get(
-        '/opsdb/interfaces/mx1.ams.nl.geant.net/ae0.0',
+        '/testing/opsdb/interfaces/mx1.ams.nl.geant.net/ae0.0',
         headers=DEFAULT_REQUEST_HEADERS)
     assert rv.status_code == 200
     assert rv.is_json
@@ -44,7 +87,7 @@ def test_get_interface_info_for_equipment_and_interface(
 
 def test_get_children(client_with_mocked_data):
     rv = client_with_mocked_data.get(
-        '/opsdb/circuit-hierarchy/children/12363',
+        '/testing/opsdb/circuit-hierarchy/children/12363',
         headers=DEFAULT_REQUEST_HEADERS)
     assert rv.status_code == 200
     assert rv.is_json
@@ -53,7 +96,7 @@ def test_get_children(client_with_mocked_data):
 
 def test_get_parents(client_with_mocked_data):
     rv = client_with_mocked_data.get(
-        '/opsdb/circuit-hierarchy/parents/11725',
+        '/testing/opsdb/circuit-hierarchy/parents/11725',
         headers=DEFAULT_REQUEST_HEADERS)
     assert rv.status_code == 200
     assert rv.is_json