You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@accumulo.apache.org by ct...@apache.org on 2022/05/10 21:17:08 UTC

[accumulo] 05/05: Single node prop store (#2569)

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

ctubbsii pushed a commit to branch single-node-props
in repository https://gitbox.apache.org/repos/asf/accumulo.git

commit 47de8950844babf3124df430622e7c1365e0d0ea
Author: EdColeman <de...@etcoleman.com>
AuthorDate: Tue May 10 17:13:08 2022 -0400

    Single node prop store (#2569)
    
    Convert multiple node property configuration to a single-node encoded store.
    This reduces the number of ZooKeeper watchers needed to track property
    changes, and sets the groundwork for atomic changes to multiple properties
    in the future.
    
    Co-authored-by: Ed Coleman etcoleman <ed...@apache.org>
    Co-authored-by: Christopher Tubbs <ct...@apache.org>
---
 .../org/apache/accumulo/core/conf/Property.java    |   2 +-
 .../accumulo/core/metrics/MetricsProducer.java     |  43 ++
 .../apache/accumulo/core/conf/PropertyTest.java    |   9 +
 server/base/pom.xml                                |   8 +
 .../org/apache/accumulo/server/ServerContext.java  |  32 +-
 .../server/conf/NamespaceConfiguration.java        | 119 ++--
 .../server/conf/RuntimeFixedProperties.java        | 106 ++++
 .../server/conf/ServerConfigurationFactory.java    |  24 +-
 .../accumulo/server/conf/SystemConfiguration.java  |  66 ++
 .../accumulo/server/conf/TableConfiguration.java   | 108 +---
 .../server/conf/ZooBasedConfiguration.java         | 164 +++++
 .../server/conf/ZooCachePropertyAccessor.java      | 172 ------
 .../accumulo/server/conf/ZooConfiguration.java     | 149 -----
 .../server/conf/codec/VersionedPropCodec.java      |  92 ++-
 .../server/conf/codec/VersionedPropGzipCodec.java  |   2 +-
 .../server/conf/codec/VersionedProperties.java     |  32 +-
 .../server/conf/store/NamespacePropKey.java        |  65 ++
 .../accumulo/server/conf/store/PropCache.java      |  36 ++
 .../accumulo/server/conf/store/PropCacheKey.java   | 141 +++++
 .../server/conf/store/PropChangeListener.java      |  43 ++
 .../accumulo/server/conf/store/PropStore.java      | 117 ++++
 .../accumulo/server/conf/store/SystemPropKey.java  |  56 ++
 .../accumulo/server/conf/store/TablePropKey.java   |  59 ++
 .../conf/store/impl/PropCacheCaffeineImpl.java     | 142 +++++
 .../server/conf/store/impl/PropStoreEventTask.java | 110 ++++
 .../server/conf/store/impl/PropStoreMetrics.java   |  92 +++
 .../server/conf/store/impl/PropStoreWatcher.java   | 252 ++++++++
 .../server/conf/store/impl/ReadyMonitor.java       | 143 +++++
 .../server/conf/store/impl/ZooPropLoader.java      | 157 +++++
 .../server/conf/store/impl/ZooPropStore.java       | 390 ++++++++++++
 .../server/conf/util/ConfigPropertyUpgrader.java   | 138 +++++
 .../server/conf/util/ConfigTransformer.java        | 395 ++++++++++++
 .../accumulo/server/conf/util/PropSnapshot.java    | 141 +++++
 .../accumulo/server/conf/util/TransformToken.java  | 209 +++++++
 .../server/init/FileSystemInitializer.java         |  35 +-
 .../apache/accumulo/server/init/Initialize.java    |  24 +-
 .../accumulo/server/init/ZooKeeperInitializer.java |  82 ++-
 .../accumulo/server/tables/TableManager.java       |  59 +-
 .../accumulo/server/util/NamespacePropUtil.java    |  51 +-
 .../org/apache/accumulo/server/util/PropUtil.java  |  39 ++
 .../accumulo/server/util/SystemPropUtil.java       |  45 +-
 .../apache/accumulo/server/util/TablePropUtil.java |  55 +-
 .../apache/accumulo/server/MockServerContext.java  |  13 +
 .../server/conf/NamespaceConfigurationTest.java    | 149 +++--
 .../server/conf/RuntimeFixedPropertiesTest.java    |  87 +++
 .../conf/ServerConfigurationFactoryTest.java       | 114 ++--
 .../server/conf/SystemConfigurationTest.java       | 141 +++++
 .../server/conf/TableConfigurationTest.java        | 276 +++++++--
 .../server/conf/ZooBasedConfigurationTest.java     | 347 +++++++++++
 .../server/conf/ZooCachePropertyAccessorTest.java  | 188 ------
 .../server/conf/codec/VersionedPropCodecTest.java  |   8 +-
 .../conf/codec/VersionedPropEncryptCodec.java      |   2 +-
 .../conf/codec/VersionedPropEncryptCodecTest.java  |  28 +-
 .../conf/codec/VersionedPropGzipCodecTest.java     |  28 +-
 .../server/conf/codec/VersionedPropertiesTest.java |  39 +-
 .../server/conf/store/PropCacheKeyTest.java        | 125 ++++
 .../conf/store/impl/PropCacheCaffeineImplTest.java | 223 +++++++
 .../server/conf/store/impl/PropStoreEventTest.java | 318 ++++++++++
 .../server/conf/store/impl/ReadyMonitorTest.java   | 180 ++++++
 .../server/conf/store/impl/ZooPropLoaderTest.java  | 665 +++++++++++++++++++++
 .../server/conf/store/impl/ZooPropStoreTest.java   | 403 +++++++++++++
 .../server/conf/util/PropSnapshotTest.java         | 133 +++++
 .../BaseHostRegexTableLoadBalancerTest.java        |  39 +-
 .../server/security/UserImpersonationTest.java     |   5 +
 .../manager/ManagerClientServiceHandler.java       |  39 +-
 .../manager/tableOps/create/PopulateZookeeper.java |  17 +-
 .../create/PopulateZookeeperWithNamespace.java     |  15 +-
 .../tableImport/ImportPopulateZookeeper.java       |  17 +-
 .../accumulo/manager/upgrade/Upgrader9to10.java    |  80 +--
 .../tserver/TabletServerResourceManager.java       |   1 -
 test/pom.xml                                       |   4 +
 .../accumulo/test/TableConfigurationUpdateIT.java  |   8 +-
 .../accumulo/test/conf/PropStoreConfigIT.java      |  67 +++
 .../test/conf/store/PropCacheCaffeineImplZkIT.java | 180 ++++++
 .../test/conf/store/PropStoreZooKeeperIT.java      | 416 +++++++++++++
 .../accumulo/test/conf/store/ZooBasedConfigIT.java | 394 ++++++++++++
 .../test/conf/util/ConfigTransformerIT.java        | 181 ++++++
 .../accumulo/test/conf/util/LegacyPropData.java    | 390 ++++++++++++
 .../accumulo/test/conf/util/TransformTokenIT.java  | 161 +++++
 .../test/functional/WatchTheWatchCountIT.java      |   5 +-
 .../apache/accumulo/test/metrics/MetricsIT.java    |   4 +-
 .../apache/accumulo/test/shell/ShellServerIT.java  |  20 +-
 .../apache/accumulo/test/start/KeywordStartIT.java |   2 +
 .../test/upgrade/ConfigPropertyUpgraderIT.java     | 154 +++++
 .../upgrade/DeprecatedPropertyUpgrade9to10IT.java  |  67 ---
 85 files changed, 8584 insertions(+), 1353 deletions(-)

diff --git a/core/src/main/java/org/apache/accumulo/core/conf/Property.java b/core/src/main/java/org/apache/accumulo/core/conf/Property.java
index 9ca2fdc87a..fa0c069531 100644
--- a/core/src/main/java/org/apache/accumulo/core/conf/Property.java
+++ b/core/src/main/java/org/apache/accumulo/core/conf/Property.java
@@ -1716,7 +1716,7 @@ public enum Property {
             || key.startsWith(TABLE_COMPACTION_SELECTOR_OPTS.getKey())));
   }
 
-  private static final EnumSet<Property> fixedProperties = EnumSet.of(
+  public static final EnumSet<Property> fixedProperties = EnumSet.of(
       // port options
       GC_PORT, MANAGER_CLIENTPORT, TSERV_CLIENTPORT,
 
diff --git a/core/src/main/java/org/apache/accumulo/core/metrics/MetricsProducer.java b/core/src/main/java/org/apache/accumulo/core/metrics/MetricsProducer.java
index 1c242726f9..699ffc7d2b 100644
--- a/core/src/main/java/org/apache/accumulo/core/metrics/MetricsProducer.java
+++ b/core/src/main/java/org/apache/accumulo/core/metrics/MetricsProducer.java
@@ -530,6 +530,42 @@ import io.micrometer.core.instrument.MeterRegistry;
  * <td>Distribution Summary</td>
  * <td></td>
  * </tr>
+ * <!-- ZooKeeper property cache -->
+ * <tr>
+ * <td>N/A</td>
+ * <td>N/A</td>
+ * <td>{@link #METRICS_PROPSTORE_LOAD_TIMER}</td>
+ * <td>Timer</td>
+ * <td></td>
+ * </tr>
+ * <tr>
+ * <td>N/A</td>
+ * <td>N/A</td>
+ * <td>{@link #METRICS_PROPSTORE_REFRESH_COUNT}</td>
+ * <td>Counter</td>
+ * <td></td>
+ * </tr>
+ * <tr>
+ * <td>N/A</td>
+ * <td>N/A</td>
+ * <td>{@link #METRICS_PROPSTORE_REFRESH_LOAD_COUNT}</td>
+ * <td>Counter</td>
+ * <td></td>
+ * </tr>
+ * <tr>
+ * <td>N/A</td>
+ * <td>N/A</td>
+ * <td>{@link #METRICS_PROPSTORE_EVICTION_COUNT}</td>
+ * <td>Counter</td>
+ * <td></td>
+ * </tr>
+ * <tr>
+ * <td>N/A</td>
+ * <td>N/A</td>
+ * <td>{@link #METRICS_PROPSTORE_ZK_ERROR_COUNT}</td>
+ * <td>Counter</td>
+ * <td></td>
+ * </tr>
  * </table>
  *
  * @since 2.1.0
@@ -617,6 +653,13 @@ public interface MetricsProducer {
   String METRICS_UPDATE_WALOG_WRITE = METRICS_UPDATE_PREFIX + "walog.write";
   String METRICS_UPDATE_MUTATION_ARRAY_SIZE = METRICS_UPDATE_PREFIX + "mutation.arrays.size";
 
+  String METRICS_PROPSTORE_PREFIX = "accumulo.prop.store.";
+  String METRICS_PROPSTORE_LOAD_TIMER = METRICS_PROPSTORE_PREFIX + "load";
+  String METRICS_PROPSTORE_REFRESH_COUNT = METRICS_PROPSTORE_PREFIX + "refresh";
+  String METRICS_PROPSTORE_REFRESH_LOAD_COUNT = METRICS_PROPSTORE_PREFIX + "refresh.load";
+  String METRICS_PROPSTORE_EVICTION_COUNT = METRICS_PROPSTORE_PREFIX + "evictions";
+  String METRICS_PROPSTORE_ZK_ERROR_COUNT = METRICS_PROPSTORE_PREFIX + "zookeeper.error";
+
   /**
    * Build Micrometer Meter objects and register them with the registry
    */
diff --git a/core/src/test/java/org/apache/accumulo/core/conf/PropertyTest.java b/core/src/test/java/org/apache/accumulo/core/conf/PropertyTest.java
index d568bf9104..5f392ce5f7 100644
--- a/core/src/test/java/org/apache/accumulo/core/conf/PropertyTest.java
+++ b/core/src/test/java/org/apache/accumulo/core/conf/PropertyTest.java
@@ -20,6 +20,7 @@ package org.apache.accumulo.core.conf;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -186,4 +187,12 @@ public class PropertyTest {
 
     assertFalse(Property.isValidTablePropertyKey("abc.def"));
   }
+
+  @Test
+  public void testFixedPropertiesNonNull() {
+    Property.fixedProperties.forEach(p -> {
+      assertNotNull(p.getDefaultValue());
+      assertFalse(p.getDefaultValue().isBlank());
+    });
+  }
 }
diff --git a/server/base/pom.xml b/server/base/pom.xml
index 0f3bc8d98f..edb18c01b9 100644
--- a/server/base/pom.xml
+++ b/server/base/pom.xml
@@ -35,6 +35,10 @@
       <groupId>com.beust</groupId>
       <artifactId>jcommander</artifactId>
     </dependency>
+    <dependency>
+      <groupId>com.github.ben-manes.caffeine</groupId>
+      <artifactId>caffeine</artifactId>
+    </dependency>
     <dependency>
       <groupId>com.google.auto.service</groupId>
       <artifactId>auto-service</artifactId>
@@ -100,6 +104,10 @@
       <groupId>org.apache.zookeeper</groupId>
       <artifactId>zookeeper-jute</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.checkerframework</groupId>
+      <artifactId>checker-qual</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.slf4j</groupId>
       <artifactId>slf4j-api</artifactId>
diff --git a/server/base/src/main/java/org/apache/accumulo/server/ServerContext.java b/server/base/src/main/java/org/apache/accumulo/server/ServerContext.java
index 604e95e033..0242f8386f 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/ServerContext.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/ServerContext.java
@@ -59,13 +59,15 @@ import org.apache.accumulo.core.util.AddressUtil;
 import org.apache.accumulo.core.util.Pair;
 import org.apache.accumulo.core.util.threads.ThreadPools;
 import org.apache.accumulo.core.util.threads.Threads;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
 import org.apache.accumulo.fate.zookeeper.ZooReader;
 import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
 import org.apache.accumulo.server.conf.NamespaceConfiguration;
 import org.apache.accumulo.server.conf.ServerConfigurationFactory;
+import org.apache.accumulo.server.conf.SystemConfiguration;
 import org.apache.accumulo.server.conf.TableConfiguration;
-import org.apache.accumulo.server.conf.ZooConfiguration;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
 import org.apache.accumulo.server.fs.VolumeManager;
 import org.apache.accumulo.server.metadata.ServerAmpleImpl;
 import org.apache.accumulo.server.rpc.SaslServerConnectionParams;
@@ -76,6 +78,8 @@ import org.apache.accumulo.server.security.SecurityUtil;
 import org.apache.accumulo.server.security.delegation.AuthenticationTokenSecretManager;
 import org.apache.accumulo.server.tables.TableManager;
 import org.apache.accumulo.server.tablets.UniqueNameAllocator;
+import org.apache.accumulo.server.util.NamespacePropUtil;
+import org.apache.accumulo.server.util.TablePropUtil;
 import org.apache.hadoop.fs.Path;
 import org.apache.hadoop.security.UserGroupInformation;
 import org.apache.zookeeper.KeeperException;
@@ -92,16 +96,19 @@ public class ServerContext extends ClientContext {
   private final ServerInfo info;
   private final ZooReaderWriter zooReaderWriter;
   private final ServerDirs serverDirs;
+  private final PropStore propStore;
 
   // lazily loaded resources, only loaded when needed
   private final Supplier<TableManager> tableManager;
   private final Supplier<UniqueNameAllocator> nameAllocator;
   private final Supplier<ServerConfigurationFactory> serverConfFactory;
-  private final Supplier<AccumuloConfiguration> systemConfig;
+  private final Supplier<SystemConfiguration> systemConfig;
   private final Supplier<AuthenticationTokenSecretManager> secretManager;
   private final Supplier<CryptoService> cryptoService;
   private final Supplier<ScheduledThreadPoolExecutor> sharedScheduledThreadPool;
   private final Supplier<AuditedSecurityOperation> securityOperation;
+  private final Supplier<TablePropUtil> tablePropUtilSupplier;
+  private final Supplier<NamespacePropUtil> namespacePropUtilSupplier;
 
   public ServerContext(SiteConfiguration siteConfig) {
     this(new ServerInfo(siteConfig));
@@ -112,13 +119,12 @@ public class ServerContext extends ClientContext {
     this.info = info;
     zooReaderWriter = new ZooReaderWriter(info.getSiteConfiguration());
     serverDirs = info.getServerDirs();
+    propStore = ZooPropStore.initialize(info.getInstanceID(), zooReaderWriter);
 
     tableManager = memoize(() -> new TableManager(this));
     nameAllocator = memoize(() -> new UniqueNameAllocator(this));
     serverConfFactory = memoize(() -> new ServerConfigurationFactory(this, getSiteConfiguration()));
-    // system configuration uses its own instance of ZooCache
-    // this could be useful to keep its update counter independent
-    systemConfig = memoize(() -> new ZooConfiguration(this, new ZooCache(getZooReader(), null),
+    systemConfig = memoize(() -> new SystemConfiguration(this, SystemPropKey.of(getInstanceID()),
         getSiteConfiguration()));
     secretManager = memoize(() -> new AuthenticationTokenSecretManager(getInstanceID(),
         getConfiguration().getTimeInMillis(Property.GENERAL_DELEGATION_TOKEN_LIFETIME)));
@@ -129,6 +135,8 @@ public class ServerContext extends ClientContext {
     securityOperation =
         memoize(() -> new AuditedSecurityOperation(this, SecurityOperation.getAuthorizor(this),
             SecurityOperation.getAuthenticator(this), SecurityOperation.getPermHandler(this)));
+    tablePropUtilSupplier = memoize(() -> new TablePropUtil(this));
+    namespacePropUtilSupplier = memoize(() -> new NamespacePropUtil(this));
   }
 
   /**
@@ -434,6 +442,10 @@ public class ServerContext extends ClientContext {
     return sharedScheduledThreadPool.get();
   }
 
+  public PropStore getPropStore() {
+    return propStore;
+  }
+
   @Override
   protected long getTransportPoolMaxAgeMillis() {
     return getClientTimeoutInMillis();
@@ -443,4 +455,12 @@ public class ServerContext extends ClientContext {
     return securityOperation.get();
   }
 
+  public TablePropUtil tablePropUtil() {
+    return tablePropUtilSupplier.get();
+  }
+
+  public NamespacePropUtil namespacePropUtil() {
+    return namespacePropUtilSupplier.get();
+  }
+
 }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/NamespaceConfiguration.java b/server/base/src/main/java/org/apache/accumulo/server/conf/NamespaceConfiguration.java
index e3eb342332..2d8b19d615 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/NamespaceConfiguration.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/NamespaceConfiguration.java
@@ -19,123 +19,78 @@
 package org.apache.accumulo.server.conf;
 
 import java.util.Map;
-import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Predicate;
 
-import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.clientImpl.Namespace;
 import org.apache.accumulo.core.conf.AccumuloConfiguration;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.data.NamespaceId;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooCacheFactory;
 import org.apache.accumulo.server.ServerContext;
-import org.apache.accumulo.server.conf.ZooCachePropertyAccessor.PropCacheKey;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-public class NamespaceConfiguration extends AccumuloConfiguration {
+public class NamespaceConfiguration extends ZooBasedConfiguration {
 
-  private static final Map<PropCacheKey,ZooCache> propCaches = new java.util.HashMap<>();
-
-  private final AccumuloConfiguration parent;
-  private final AtomicReference<ZooCachePropertyAccessor> propCacheAccessor =
-      new AtomicReference<>();
-  protected NamespaceId namespaceId = null;
+  private static final Logger log = LoggerFactory.getLogger(NamespaceConfiguration.class);
   protected ServerContext context;
-  private ZooCacheFactory zcf = new ZooCacheFactory();
-  private final String path;
 
-  public NamespaceConfiguration(NamespaceId namespaceId, ServerContext context,
+  public NamespaceConfiguration(ServerContext context, NamespaceId namespaceId,
       AccumuloConfiguration parent) {
-    this.context = context;
-    this.parent = parent;
-    this.namespaceId = namespaceId;
-    this.path = context.getZooKeeperRoot() + Constants.ZNAMESPACES + "/" + namespaceId
-        + Constants.ZNAMESPACE_CONF;
-  }
-
-  void setZooCacheFactory(ZooCacheFactory zcf) {
-    this.zcf = zcf;
-  }
-
-  private ZooCache getZooCache() {
-    synchronized (propCaches) {
-      PropCacheKey key = new PropCacheKey(context.getInstanceID(), namespaceId.canonical());
-      return propCaches.computeIfAbsent(key,
-          k -> zcf.getZooCache(context.getZooKeepers(), context.getZooKeepersSessionTimeOut()));
-    }
-  }
-
-  private ZooCachePropertyAccessor getPropCacheAccessor() {
-    // updateAndGet below always calls compare and set, so avoid if not null
-    ZooCachePropertyAccessor zcpa = propCacheAccessor.get();
-    if (zcpa != null)
-      return zcpa;
-
-    return propCacheAccessor
-        .updateAndGet(pca -> pca == null ? new ZooCachePropertyAccessor(getZooCache()) : pca);
-  }
-
-  private String getPath() {
-    return path;
-  }
-
-  @Override
-  public boolean isPropertySet(Property prop) {
-    return getPropCacheAccessor().isPropertySet(prop, getPath()) || parent.isPropertySet(prop);
+    super(log, context, NamespacePropKey.of(context, namespaceId), parent);
   }
 
   @Override
   public String get(Property property) {
+
     String key = property.getKey();
-    AccumuloConfiguration getParent;
-    if (namespaceId.equals(Namespace.ACCUMULO.id()) && isIteratorOrConstraint(key)) {
+
+    var namespaceId = getPropCacheKey().getId();
+    if (namespaceId != null && namespaceId.equals(Namespace.ACCUMULO.id())
+        && isIteratorOrConstraint(key)) {
       // ignore iterators from parent if system namespace
-      getParent = null;
-    } else {
-      getParent = parent;
+      return null;
     }
-    return getPropCacheAccessor().get(property, getPath(), getParent);
+
+    Map<String,String> theseProps = getSnapshot();
+    String value = theseProps.get(key);
+
+    if (value != null) {
+      return value;
+    }
+
+    return getParent().get(key);
   }
 
+  /**
+   * exclude system iterators/constraints from the system namespace so that they don't affect the
+   * metadata or root tables.
+   */
   @Override
   public void getProperties(Map<String,String> props, Predicate<String> filter) {
     Predicate<String> parentFilter = filter;
     // exclude system iterators/constraints from the system namespace
     // so they don't affect the metadata or root tables.
     if (getNamespaceId().equals(Namespace.ACCUMULO.id()))
-      parentFilter = key -> isIteratorOrConstraint(key) ? false : filter.test(key);
+      parentFilter = key -> !isIteratorOrConstraint(key) && filter.test(key);
+
+    getParent().getProperties(props, parentFilter);
 
-    getPropCacheAccessor().getProperties(props, getPath(), filter, parent, parentFilter);
+    getSnapshot().entrySet().stream().filter(e -> filter.test(e.getKey()) && e.getValue() != null)
+        .forEach(e -> props.put(e.getKey(), e.getValue()));
   }
 
   protected NamespaceId getNamespaceId() {
-    return namespaceId;
+    NamespaceId id = (NamespaceId) getPropCacheKey().getId();
+    if (id == null) {
+      throw new IllegalArgumentException(
+          "Invalid request for namespace id on " + getPropCacheKey());
+    }
+    return id;
   }
 
   static boolean isIteratorOrConstraint(String key) {
     return key.startsWith(Property.TABLE_ITERATOR_PREFIX.getKey())
         || key.startsWith(Property.TABLE_CONSTRAINT_PREFIX.getKey());
   }
-
-  @Override
-  public synchronized void invalidateCache() {
-    ZooCachePropertyAccessor pca = propCacheAccessor.get();
-
-    if (pca != null) {
-      pca.invalidateCache();
-    }
-    // Else, if the accessor is null, we could lock and double-check
-    // to see if it happened to be created so we could invalidate its cache
-    // but I don't see much benefit coming from that extra check.
-  }
-
-  @Override
-  public long getUpdateCount() {
-    return parent.getUpdateCount() + getPropCacheAccessor().getZooCache().getUpdateCount();
-  }
-
-  @Override
-  public AccumuloConfiguration getParent() {
-    return parent;
-  }
 }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/RuntimeFixedProperties.java b/server/base/src/main/java/org/apache/accumulo/server/conf/RuntimeFixedProperties.java
new file mode 100644
index 0000000000..77f6777bbf
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/RuntimeFixedProperties.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.core.conf.SiteConfiguration;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Utility class to a manage a fixed set of defined properties (designated in Properties as fixed).
+ * Certain properties are stored for persistence across restarts, they are read during start-up and
+ * remain unchanged for the life of the instance. Any updates to the properties will only be
+ * reflected with a restart.
+ * <p>
+ * Note that there are no guarantees that all services will always have the same values. If a fixed
+ * property value is changed and if all services are not restarted, they would be operating with
+ * different values.
+ */
+public class RuntimeFixedProperties {
+
+  private static final Logger log = LoggerFactory.getLogger(RuntimeFixedProperties.class);
+
+  // combined site, stored or default props.
+  private final Map<String,String> fixed = new HashMap<>();
+
+  // original, stored fixed props.
+  private final Map<String,String> origStored = new HashMap<>();
+
+  public RuntimeFixedProperties(final Map<String,String> storedProps,
+      final SiteConfiguration siteConfig) {
+    requireNonNull(siteConfig, "a site configuration must be provided");
+    requireNonNull(storedProps, "stored property map must be provided");
+    Property.fixedProperties.forEach(prop -> {
+      String key = prop.getKey();
+      String value = storedProps.get(key);
+      // use value in ZooKeeper
+      if (value != null) {
+        origStored.put(key, value);
+      } else {
+        // Not in ZK, use config or default.
+        value = siteConfig.get(key);
+      }
+      fixed.put(key, value);
+      log.trace("fixed property name: {} = {}", key, value);
+    });
+  }
+
+  @Nullable
+  public String get(final Property property) {
+    return fixed.get(property.getKey());
+  }
+
+  @VisibleForTesting
+  Map<String,String> getAll() {
+    return Collections.unmodifiableMap(fixed);
+  }
+
+  /**
+   * Test if a property has changed since this instance was created. If a property has changed, it
+   * will take effect on next restart.
+   *
+   * @param current
+   *          a map of the current stored properties.
+   * @return true if changed and restart required, false otherwise
+   */
+  @VisibleForTesting
+  boolean hasChanged(final Map<String,String> current) {
+    for (Property prop : Property.fixedProperties) {
+      var key = prop.getKey();
+      if (current.containsKey(key)) {
+        if (!current.get(key).equals(fixed.get(key))) {
+          return true;
+        }
+      } else if (origStored.containsKey(key)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/ServerConfigurationFactory.java b/server/base/src/main/java/org/apache/accumulo/server/conf/ServerConfigurationFactory.java
index d968af064e..ac7dc1ce51 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/ServerConfigurationFactory.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/ServerConfigurationFactory.java
@@ -29,9 +29,8 @@ import org.apache.accumulo.core.conf.DefaultConfiguration;
 import org.apache.accumulo.core.conf.SiteConfiguration;
 import org.apache.accumulo.core.data.NamespaceId;
 import org.apache.accumulo.core.data.TableId;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooCacheFactory;
 import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
 
 import com.google.common.base.Suppliers;
 
@@ -45,33 +44,21 @@ public class ServerConfigurationFactory extends ServerConfiguration {
   private final Map<NamespaceId,NamespaceConfiguration> namespaceConfigs =
       new ConcurrentHashMap<>();
 
-  private final Supplier<ZooConfiguration> systemConfig;
-
   private final ServerContext context;
   private final SiteConfiguration siteConfig;
-  private ZooCacheFactory zcf = new ZooCacheFactory();
+  private final Supplier<SystemConfiguration> systemConfig;
 
   public ServerConfigurationFactory(ServerContext context, SiteConfiguration siteConfig) {
     this.context = context;
     this.siteConfig = siteConfig;
-    systemConfig = Suppliers.memoize(() -> {
-      // Force the creation of a new ZooCache instead of using a shared one.
-      // This is done so that the ZooCache will update less often, causing the
-      // configuration update count to increment more slowly.
-      ZooCache propCache =
-          zcf.getNewZooCache(context.getZooKeepers(), context.getZooKeepersSessionTimeOut());
-      return new ZooConfiguration(context, propCache, siteConfig);
-    });
+    systemConfig = Suppliers.memoize(
+        () -> new SystemConfiguration(context, SystemPropKey.of(context), getSiteConfiguration()));
   }
 
   public ServerContext getServerContext() {
     return context;
   }
 
-  void setZooCacheFactory(ZooCacheFactory zcf) {
-    this.zcf = zcf;
-  }
-
   public SiteConfiguration getSiteConfiguration() {
     return siteConfig;
   }
@@ -112,8 +99,7 @@ public class ServerConfigurationFactory extends ServerConfiguration {
   @Override
   public NamespaceConfiguration getNamespaceConfiguration(NamespaceId namespaceId) {
     return namespaceConfigs.computeIfAbsent(namespaceId, key -> {
-      var conf = new NamespaceConfiguration(namespaceId, context, getSystemConfiguration());
-      conf.setZooCacheFactory(zcf);
+      var conf = new NamespaceConfiguration(context, namespaceId, getSystemConfiguration());
       ConfigCheckUtil.validate(conf);
       return conf;
     });
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/SystemConfiguration.java b/server/base/src/main/java/org/apache/accumulo/server/conf/SystemConfiguration.java
new file mode 100644
index 0000000000..661f3fbcae
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/SystemConfiguration.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf;
+
+import org.apache.accumulo.core.conf.AccumuloConfiguration;
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SystemConfiguration extends ZooBasedConfiguration {
+
+  private static final Logger log = LoggerFactory.getLogger(SystemConfiguration.class);
+
+  private final RuntimeFixedProperties runtimeFixedProps;
+
+  public SystemConfiguration(ServerContext context, SystemPropKey propCacheKey,
+      AccumuloConfiguration parent) {
+    super(log, context, propCacheKey, parent);
+    runtimeFixedProps = new RuntimeFixedProperties(getSnapshot(), context.getSiteConfiguration());
+  }
+
+  @Override
+  public String get(Property property) {
+    log.trace("system config get() - property request for {}", property);
+    if (Property.isFixedZooPropertyKey(property))
+      return runtimeFixedProps.get(property);
+
+    String key = property.getKey();
+    String value = null;
+    if (Property.isValidZooPropertyKey(key)) {
+      value = getSnapshot().get(key);
+    }
+
+    if (value == null || !property.getType().isValidFormat(value)) {
+      if (value != null) {
+        log.error("Using parent value for {} due to improperly formatted {}: {}", key,
+            property.getType(), value);
+      }
+      value = getParent().get(property);
+    }
+    return value;
+  }
+
+  @Override
+  public boolean isPropertySet(Property prop) {
+    return runtimeFixedProps.get(prop) != null || super.isPropertySet(prop);
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/TableConfiguration.java b/server/base/src/main/java/org/apache/accumulo/server/conf/TableConfiguration.java
index 7de0d53bda..14be47a739 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/TableConfiguration.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/TableConfiguration.java
@@ -18,19 +18,14 @@
  */
 package org.apache.accumulo.server.conf;
 
-import static java.util.Objects.requireNonNull;
-
 import java.util.Collections;
 import java.util.EnumMap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
-import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.classloader.ClassLoaderUtil;
 import org.apache.accumulo.core.conf.AccumuloConfiguration;
 import org.apache.accumulo.core.conf.Property;
@@ -41,21 +36,16 @@ import org.apache.accumulo.core.iteratorsImpl.IteratorConfigUtil;
 import org.apache.accumulo.core.spi.common.ServiceEnvironment;
 import org.apache.accumulo.core.spi.compaction.CompactionDispatcher;
 import org.apache.accumulo.core.spi.scan.ScanDispatcher;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooCacheFactory;
 import org.apache.accumulo.server.ServerContext;
 import org.apache.accumulo.server.ServiceEnvironmentImpl;
-import org.apache.accumulo.server.conf.ZooCachePropertyAccessor.PropCacheKey;
-
-public class TableConfiguration extends AccumuloConfiguration {
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-  private static final Map<PropCacheKey,ZooCache> propCaches = new java.util.HashMap<>();
+public class TableConfiguration extends ZooBasedConfiguration {
 
-  private final AtomicReference<ZooCachePropertyAccessor> propCacheAccessor =
-      new AtomicReference<>();
-  private final ServerContext context;
-  private final NamespaceConfiguration parent;
-  private ZooCacheFactory zcf = new ZooCacheFactory();
+  private final static Logger log = LoggerFactory.getLogger(TableConfiguration.class);
 
   private final TableId tableId;
 
@@ -65,9 +55,8 @@ public class TableConfiguration extends AccumuloConfiguration {
   private final Deriver<CompactionDispatcher> compactionDispatchDeriver;
 
   public TableConfiguration(ServerContext context, TableId tableId, NamespaceConfiguration parent) {
-    this.context = requireNonNull(context);
-    this.tableId = requireNonNull(tableId);
-    this.parent = requireNonNull(parent);
+    super(log, context, TablePropKey.of(context, tableId), parent);
+    this.tableId = tableId;
 
     iteratorConfig = new EnumMap<>(IteratorScope.class);
     for (IteratorScope scope : IteratorScope.values()) {
@@ -84,78 +73,51 @@ public class TableConfiguration extends AccumuloConfiguration {
         newDeriver(conf -> createCompactionDispatcher(conf, context, tableId));
   }
 
-  void setZooCacheFactory(ZooCacheFactory zcf) {
-    this.zcf = zcf;
-  }
-
-  private ZooCache getZooCache() {
-    synchronized (propCaches) {
-      PropCacheKey key = new PropCacheKey(context.getInstanceID(), tableId.canonical());
-      ZooCache propCache = propCaches.get(key);
-      if (propCache == null) {
-        propCache = zcf.getZooCache(context.getZooKeepers(), context.getZooKeepersSessionTimeOut());
-        propCaches.put(key, propCache);
-      }
-      return propCache;
-    }
-  }
-
-  private ZooCachePropertyAccessor getPropCacheAccessor() {
-    // updateAndGet below always calls compare and set, so avoid if not null
-    ZooCachePropertyAccessor zcpa = propCacheAccessor.get();
-    if (zcpa != null) {
-      return zcpa;
+  @Override
+  public boolean isPropertySet(Property prop) {
+    if (_isPropertySet(prop)) {
+      return true;
     }
 
-    return propCacheAccessor
-        .updateAndGet(pca -> pca == null ? new ZooCachePropertyAccessor(getZooCache()) : pca);
+    return getParent().isPropertySet(prop);
   }
 
-  private String getPath() {
-    return context.getZooKeeperRoot() + Constants.ZTABLES + "/" + tableId + Constants.ZTABLE_CONF;
-  }
-
-  @Override
-  public boolean isPropertySet(Property prop) {
-    return getPropCacheAccessor().isPropertySet(prop, getPath()) || parent.isPropertySet(prop);
+  private boolean _isPropertySet(Property property) {
+    Map<String,String> propMap = getSnapshot();
+    return propMap.get(property.getKey()) != null;
   }
 
   @Override
   public String get(Property property) {
-    return getPropCacheAccessor().get(property, getPath(), parent);
+    String value = _get(property);
+    if (value != null) {
+      return value;
+    }
+    AccumuloConfiguration parent = getParent();
+    if (parent != null) {
+      return parent.get(property);
+    }
+    return null;
   }
 
-  @Override
-  public void getProperties(Map<String,String> props, Predicate<String> filter) {
-    getPropCacheAccessor().getProperties(props, getPath(), filter, parent, null);
+  @Nullable
+  private String _get(Property property) {
+    Map<String,String> propMap = getSnapshot();
+    if (propMap == null) {
+      return null;
+    }
+    return propMap.get(property.getKey());
   }
 
   public TableId getTableId() {
     return tableId;
   }
 
-  @Override
-  public synchronized void invalidateCache() {
-    ZooCachePropertyAccessor pca = propCacheAccessor.get();
-
-    if (pca != null) {
-      pca.invalidateCache();
-    }
-    // Else, if the accessor is null, we could lock and double-check
-    // to see if it happened to be created so we could invalidate its cache
-    // but I don't see much benefit coming from that extra check.
-  }
-
   @Override
   public String toString() {
     return this.getClass().getSimpleName();
   }
 
-  @Override
-  public long getUpdateCount() {
-    return parent.getUpdateCount() + getPropCacheAccessor().getZooCache().getUpdateCount();
-  }
-
   public static class ParsedIteratorConfig {
     private final List<IterInfo> tableIters;
     private final Map<String,Map<String,String>> tableOpts;
@@ -219,6 +181,7 @@ public class TableConfiguration extends AccumuloConfiguration {
 
   private static CompactionDispatcher createCompactionDispatcher(AccumuloConfiguration conf,
       ServerContext context, TableId tableId) {
+
     CompactionDispatcher newDispatcher = Property.createTableInstanceFromPropertyName(conf,
         Property.TABLE_COMPACTION_DISPATCHER, CompactionDispatcher.class, null);
 
@@ -255,9 +218,4 @@ public class TableConfiguration extends AccumuloConfiguration {
   public CompactionDispatcher getCompactionDispatcher() {
     return compactionDispatchDeriver.derive();
   }
-
-  @Override
-  public AccumuloConfiguration getParent() {
-    return parent;
-  }
 }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/ZooBasedConfiguration.java b/server/base/src/main/java/org/apache/accumulo/server/conf/ZooBasedConfiguration.java
new file mode 100644
index 0000000000..2a847af26b
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/ZooBasedConfiguration.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.Map;
+import java.util.function.Predicate;
+
+import org.apache.accumulo.core.conf.AccumuloConfiguration;
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.util.PropSnapshot;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Instances maintain a local cache of the AccumuloConfiguration hierarchy that will be consistent
+ * with stored properties.
+ * <p>
+ * When calling getProperties - the local copy will be updated if ZooKeeper changes have been
+ * received.
+ * <p>
+ * The getUpdateCount() provides an optimization for clients - the count can be used to detect
+ * changes without reading the properties. When the update count changes, the next getProperties
+ * call will update the local copy and the change count.
+ */
+public class ZooBasedConfiguration extends AccumuloConfiguration {
+
+  protected final Logger log;
+  private final AccumuloConfiguration parent;
+  private final PropCacheKey<?> propCacheKey;
+
+  private final PropSnapshot propSnapshot;
+
+  public ZooBasedConfiguration(Logger log, ServerContext context, PropCacheKey<?> propCacheKey,
+      AccumuloConfiguration parent) {
+    this.log = requireNonNull(log, "a Logger must be supplied");
+    requireNonNull(context, "the context cannot be null");
+    this.propCacheKey = requireNonNull(propCacheKey, "a PropCacheId must be supplied");
+    this.parent = requireNonNull(parent, "An AccumuloConfiguration parent must be supplied");
+
+    propSnapshot = PropSnapshot.create(propCacheKey, context.getPropStore());
+  }
+
+  @VisibleForTesting
+  public void zkChangeEvent(PropCacheKey<?> propCacheKey) {
+    propSnapshot.zkChangeEvent(propCacheKey);
+  }
+
+  public long getDataVersion() {
+    return propSnapshot.getVersionedProperties().getDataVersion();
+  }
+
+  /**
+   * The update count is the sum of the change count of this configuration and the change counts of
+   * the parents. The count is used to detect if any changes occurred in the configuration hierarchy
+   * and if the configuration needs to be recalculated to maintain consistency with values in the
+   * backend store.
+   * <p>
+   * The count is required to be an increasing value.
+   */
+  @Override
+  public long getUpdateCount() {
+    long count = 0;
+    long dataVersion = 0;
+    for (AccumuloConfiguration p = this; p != null; p = p.getParent()) {
+      if (p instanceof ZooBasedConfiguration) {
+        dataVersion = ((ZooBasedConfiguration) p).getDataVersion();
+      } else {
+        dataVersion = p.getUpdateCount();
+      }
+      count += dataVersion;
+    }
+
+    log.trace("update count result for: {} - data version: {} update: {}", propCacheKey,
+        dataVersion, count);
+    return count;
+  }
+
+  @Override
+  public AccumuloConfiguration getParent() {
+    return parent;
+  }
+
+  public PropCacheKey<?> getPropCacheKey() {
+    return propCacheKey;
+  }
+
+  @Override
+  public @Nullable String get(final Property property) {
+    Map<String,String> props = getSnapshot();
+    String value = props.get(property.getKey());
+    if (value != null) {
+      return value;
+    }
+    AccumuloConfiguration parent = getParent();
+    if (parent != null) {
+      return parent.get(property);
+    }
+    return null;
+  }
+
+  @Override
+  public void getProperties(final Map<String,String> props, final Predicate<String> filter) {
+
+    parent.getProperties(props, filter);
+
+    Map<String,String> theseProps = getSnapshot();
+
+    log.trace("getProperties() for: {} filter: {}, have: {}, passed: {}", getPropCacheKey(), filter,
+        theseProps, props);
+
+    for (Map.Entry<String,String> p : theseProps.entrySet()) {
+      if (filter.test(p.getKey()) && p.getValue() != null) {
+        log.trace("passed filter - add to map: {} = {}", p.getKey(), p.getValue());
+        props.put(p.getKey(), p.getValue());
+      }
+    }
+  }
+
+  @Override
+  public boolean isPropertySet(final Property property) {
+
+    Map<String,String> theseProps = getSnapshot();
+
+    if (theseProps.get(property.getKey()) != null) {
+      return true;
+    }
+
+    return getParent().isPropertySet(property);
+
+  }
+
+  public @NonNull Map<String,String> getSnapshot() {
+    return propSnapshot.getVersionedProperties().asMap();
+  }
+
+  @Override
+  public void invalidateCache() {
+    propSnapshot.requireUpdate();
+  }
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/ZooCachePropertyAccessor.java b/server/base/src/main/java/org/apache/accumulo/server/conf/ZooCachePropertyAccessor.java
deleted file mode 100644
index 9e6799b1f4..0000000000
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/ZooCachePropertyAccessor.java
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-package org.apache.accumulo.server.conf;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import java.util.List;
-import java.util.Map;
-import java.util.function.Predicate;
-
-import org.apache.accumulo.core.conf.AccumuloConfiguration;
-import org.apache.accumulo.core.conf.Property;
-import org.apache.accumulo.core.data.InstanceId;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * A helper object for accessing properties in a {@link ZooCache}.
- */
-public class ZooCachePropertyAccessor {
-  private static final Logger log = LoggerFactory.getLogger(ZooCachePropertyAccessor.class);
-
-  static class PropCacheKey {
-    final InstanceId instanceId;
-    final String scope;
-
-    PropCacheKey(InstanceId iid, String s) {
-      instanceId = iid;
-      scope = s;
-    }
-
-    @Override
-    public boolean equals(Object other) {
-      if (this == other) {
-        return true;
-      }
-      if (!(other instanceof PropCacheKey)) {
-        return false;
-      }
-      PropCacheKey o = (PropCacheKey) other;
-      return (instanceId.equals(o.instanceId) && scope.equals(o.scope));
-    }
-
-    @Override
-    public int hashCode() {
-      int c = 17;
-      c = (37 * c) + instanceId.hashCode();
-      c = (37 * c) + scope.hashCode();
-      return c;
-    }
-  }
-
-  private final ZooCache propCache;
-
-  /**
-   * Creates a new accessor.
-   *
-   * @param propCache
-   *          property cache
-   */
-  ZooCachePropertyAccessor(ZooCache propCache) {
-    this.propCache = propCache;
-  }
-
-  /**
-   * Gets the property cache accessed by this object.
-   *
-   * @return property cache
-   */
-  ZooCache getZooCache() {
-    return propCache;
-  }
-
-  boolean isPropertySet(Property property, String path) {
-    return propCache.get(path + "/" + property.getKey()) != null;
-  }
-
-  /**
-   * Gets a property. If the property is not in ZooKeeper or is present but an invalid format for
-   * the property type, the parent configuration is consulted (if provided).
-   *
-   * @param property
-   *          property to get
-   * @param path
-   *          ZooKeeper path where properties lie
-   * @param parent
-   *          parent configuration (optional)
-   * @return property value, or null if not found
-   */
-  String get(Property property, String path, AccumuloConfiguration parent) {
-    String key = property.getKey();
-    String value = get(path + "/" + key);
-
-    if (value == null || !property.getType().isValidFormat(value)) {
-      if (value != null) {
-        log.error("Using default value for {} due to improperly formatted {}: {}", key,
-            property.getType(), value);
-      }
-      if (parent != null) {
-        value = parent.get(property);
-      }
-    }
-    return value;
-  }
-
-  private String get(String path) {
-    byte[] v = propCache.get(path);
-    if (v != null) {
-      return new String(v, UTF_8);
-    } else {
-      return null;
-    }
-  }
-
-  /**
-   * Gets all properties into the given map. Properties are filtered using the given filter.
-   * Properties from a parent configuration are also added to the map and filtered, either using a
-   * separate filter or, if not specified, the other filter.
-   *
-   * @param props
-   *          map to populate with properties
-   * @param path
-   *          ZooKeeper path where properties lie
-   * @param filter
-   *          property filter (required)
-   * @param parent
-   *          parent configuration (required)
-   * @param parentFilter
-   *          separate filter for parent properties (optional)
-   */
-  void getProperties(Map<String,String> props, String path, Predicate<String> filter,
-      AccumuloConfiguration parent, Predicate<String> parentFilter) {
-    parent.getProperties(props, parentFilter != null ? parentFilter : filter);
-
-    List<String> children = propCache.getChildren(path);
-    if (children != null) {
-      for (String child : children) {
-        if (child != null && filter.test(child)) {
-          String value = get(path + "/" + child);
-          if (value != null) {
-            props.put(child, value);
-          }
-        }
-      }
-    }
-  }
-
-  /**
-   * Clears the internal {@link ZooCache}.
-   */
-  void invalidateCache() {
-    propCache.clear();
-  }
-
-}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/ZooConfiguration.java b/server/base/src/main/java/org/apache/accumulo/server/conf/ZooConfiguration.java
deleted file mode 100644
index 7d2306b6d7..0000000000
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/ZooConfiguration.java
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-package org.apache.accumulo.server.conf;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Predicate;
-
-import org.apache.accumulo.core.Constants;
-import org.apache.accumulo.core.conf.AccumuloConfiguration;
-import org.apache.accumulo.core.conf.Property;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooReader;
-import org.apache.accumulo.server.ServerContext;
-import org.apache.zookeeper.KeeperException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ZooConfiguration extends AccumuloConfiguration {
-
-  private static final Logger log = LoggerFactory.getLogger(ZooConfiguration.class);
-
-  private final ServerContext context;
-  private final ZooCache propCache;
-  private final AccumuloConfiguration parent;
-  private final Map<String,String> fixedProps = Collections.synchronizedMap(new HashMap<>());
-  private final String propPathPrefix;
-
-  public ZooConfiguration(ServerContext context, ZooCache propCache, AccumuloConfiguration parent) {
-    this.context = context;
-    this.propCache = propCache;
-    this.parent = parent;
-    this.propPathPrefix = context.getZooKeeperRoot() + Constants.ZCONFIG;
-  }
-
-  @Override
-  public void invalidateCache() {
-    if (propCache != null)
-      propCache.clear();
-  }
-
-  private String _get(Property property) {
-    String key = property.getKey();
-    String value = null;
-
-    if (Property.isValidZooPropertyKey(key)) {
-      value = getRaw(key);
-    }
-
-    if (value == null || !property.getType().isValidFormat(value)) {
-      if (value != null)
-        log.error("Using parent value for {} due to improperly formatted {}: {}", key,
-            property.getType(), value);
-      value = parent.get(property);
-    }
-    return value;
-  }
-
-  @Override
-  public String get(Property property) {
-    if (Property.isFixedZooPropertyKey(property)) {
-      String val = fixedProps.get(property.getKey());
-      if (val != null) {
-        return val;
-      } else {
-        synchronized (fixedProps) {
-          val = _get(property);
-          fixedProps.put(property.getKey(), val);
-          return val;
-        }
-
-      }
-    } else {
-      return _get(property);
-    }
-  }
-
-  @Override
-  public boolean isPropertySet(Property prop) {
-    if (fixedProps.containsKey(prop.getKey())) {
-      return true;
-    }
-    ZooReader zr = context.getZooReaderWriter();
-    String zPath = propPathPrefix + "/" + prop.getKey();
-    try {
-      if (zr.exists(zPath)) {
-        return true;
-      }
-    } catch (KeeperException | InterruptedException e) {
-      throw new IllegalStateException(e);
-    }
-    return parent.isPropertySet(prop);
-  }
-
-  private String getRaw(String key) {
-    String zPath = propPathPrefix + "/" + key;
-    byte[] v = propCache.get(zPath);
-    String value = null;
-    if (v != null)
-      value = new String(v, UTF_8);
-    return value;
-  }
-
-  @Override
-  public void getProperties(Map<String,String> props, Predicate<String> filter) {
-    parent.getProperties(props, filter);
-
-    List<String> children = propCache.getChildren(propPathPrefix);
-    if (children != null) {
-      for (String child : children) {
-        if (child != null && filter.test(child)) {
-          String value = getRaw(child);
-          if (value != null)
-            props.put(child, value);
-        }
-      }
-    }
-  }
-
-  @Override
-  public long getUpdateCount() {
-    return parent.getUpdateCount() + propCache.getUpdateCount();
-  }
-
-  @Override
-  public AccumuloConfiguration getParent() {
-    return parent;
-  }
-}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropCodec.java b/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropCodec.java
index 43a2c4f160..96045e3c05 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropCodec.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropCodec.java
@@ -18,7 +18,7 @@
  */
 package org.apache.accumulo.server.conf.codec;
 
-import static org.apache.accumulo.server.conf.codec.VersionedProperties.tsFormatter;
+import static org.apache.accumulo.server.conf.codec.VersionedProperties.TIMESTAMP_FORMATTER;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -28,9 +28,12 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.time.Instant;
+import java.time.format.DateTimeParseException;
 import java.util.HashMap;
 import java.util.Map;
 
+import org.checkerframework.checker.nullness.qual.NonNull;
+
 /**
  * Abstract class to provide encoding / decoding of versioned properties. This class handles the
  * serialization of the metadata and subclasses are required to implement
@@ -55,6 +58,10 @@ public abstract class VersionedPropCodec {
     this.encodingOpts = encodingOpts;
   }
 
+  public static VersionedPropCodec getDefault() {
+    return VersionedPropGzipCodec.codec(true);
+  }
+
   /**
    * The general encoding options that apply to all encodings.
    *
@@ -77,13 +84,11 @@ public abstract class VersionedPropCodec {
     try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
         DataOutputStream dos = new DataOutputStream(bos)) {
 
-      // write encoding metadata
+      // write encoding info
       encodingOpts.encode(dos);
 
-      // write version metadata
-      DataVersionInfo vMetadata =
-          new DataVersionInfo(vProps.getNextVersion(), vProps.getTimestamp());
-      vMetadata.write(dos);
+      // write prop metadata
+      dos.writeUTF(TIMESTAMP_FORMATTER.format(vProps.getTimestamp()));
 
       // delegate property encoding to sub-class
       encodePayload(bos, vProps, encodingOpts);
@@ -108,7 +113,19 @@ public abstract class VersionedPropCodec {
   abstract void encodePayload(final OutputStream out, final VersionedProperties vProps,
       final EncodingOptions encodingOpts) throws IOException;
 
-  public VersionedProperties fromBytes(final byte[] bytes) throws IOException {
+  /**
+   *
+   *
+   * @param version
+   *          the data version determined by the prop store
+   * @param bytes
+   *          an array of bytes created using a PropCodec.
+   * @return the versioned properties.
+   * @throws IOException
+   *           if the an error occurs reading from the byte array.
+   */
+  public @NonNull VersionedProperties fromBytes(final int version, final byte[] bytes)
+      throws IOException {
 
     try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
         DataInputStream dis = new DataInputStream(bis)) {
@@ -121,11 +138,13 @@ public abstract class VersionedPropCodec {
                 + encodingOpts.getEncodingVersion());
       }
 
-      DataVersionInfo vMetadata = DataVersionInfo.fromDataStream(dis);
+      var timestamp = TIMESTAMP_FORMATTER.parse(dis.readUTF(), Instant::from);
 
       Map<String,String> props = decodePayload(bis, encodingOpts);
 
-      return new VersionedProperties(vMetadata.getDataVersion(), vMetadata.getTimestamp(), props);
+      return new VersionedProperties(version, timestamp, props);
+    } catch (NullPointerException | DateTimeParseException ex) {
+      throw new IllegalArgumentException("Invalid data cannot decode byte array", ex);
     }
   }
 
@@ -152,28 +171,26 @@ public abstract class VersionedPropCodec {
   }
 
   /**
-   * Extracts the data version from the encoded byte array without fully decoding the payload.
-   * Normally the data version should be obtained from a fully decoded instance of the versioned
-   * properties.
+   * Extracts the timestamp from the encoded byte array without fully decoding the payload. Normally
+   * the timestamp should be obtained from a fully decoded instance of the versioned properties.
    * <p>
    * The cost of reading the byte array from the backing store should be considered verses the
    * additional cost of decoding - with a goal of reducing data reads from the store preferred.
    * Generally reading from the store will be followed by some sort of usage which would require the
-   * full decode operation anyway.
+   * full decode operation anyway, so uses of this method should be narrow and limited.
    *
    * @param bytes
    *          serialized encoded versioned property byte array.
-   * @return the encoding version used to serialize the properties.
+   * @return the timestamp used to serialize the properties.
    */
-  public static int getDataVersion(final byte[] bytes) {
+  public static Instant readTimestamp(final byte[] bytes) {
     try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
         DataInputStream dis = new DataInputStream(bis)) {
-      // skip encoding metadata
+      // read encoding metadata according to the options.
       EncodingOptions.fromDataStream(dis);
-      return DataVersionInfo.fromDataStream(dis).getDataVersion();
-    } catch (NullPointerException | IOException ex) {
-      throw new IllegalArgumentException(
-          "Failed to read data version version from byte array provided", ex);
+      return TIMESTAMP_FORMATTER.parse(dis.readUTF(), Instant::from);
+    } catch (NullPointerException | DateTimeParseException | IOException ex) {
+      throw new IllegalArgumentException("Failed to read timestamp from byte array provided", ex);
     }
   }
 
@@ -247,39 +264,4 @@ public abstract class VersionedPropCodec {
     dos.flush();
   }
 
-  /**
-   * Helper class for reading / writing versioned properties metadata.
-   */
-  static class DataVersionInfo {
-    private final int dataVersion;
-    private final Instant timestamp;
-
-    public DataVersionInfo(final int dataVersion, final Instant timestamp) {
-      this.dataVersion = dataVersion;
-      this.timestamp = timestamp;
-    }
-
-    public static DataVersionInfo fromDataStream(final DataInputStream dis) throws IOException {
-      try {
-        var dataVersion = dis.readInt();
-        var timestamp = tsFormatter.parse(dis.readUTF(), Instant::from);
-        return new DataVersionInfo(dataVersion, timestamp);
-      } catch (Exception ex) {
-        throw new IOException("Could not parse data version info", ex);
-      }
-    }
-
-    public int getDataVersion() {
-      return dataVersion;
-    }
-
-    public Instant getTimestamp() {
-      return timestamp;
-    }
-
-    public void write(final DataOutputStream dos) throws IOException {
-      dos.writeInt(dataVersion);
-      dos.writeUTF(tsFormatter.format(timestamp));
-    }
-  }
 }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodec.java b/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodec.java
index 2a2ef0cbd5..ec6cd50820 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodec.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodec.java
@@ -45,7 +45,7 @@ public class VersionedPropGzipCodec extends VersionedPropCodec {
   void encodePayload(final OutputStream out, final VersionedProperties vProps,
       final EncodingOptions encodingOpts) throws IOException {
 
-    Map<String,String> props = vProps.getProperties();
+    Map<String,String> props = vProps.asMap();
 
     if (getEncodingOpts().isCompressed()) {
       // Write the property map to the output stream, compressing the output using GZip
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedProperties.java b/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedProperties.java
index d64254e81f..957f979837 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedProperties.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/codec/VersionedProperties.java
@@ -29,6 +29,8 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.TreeMap;
 
+import org.checkerframework.checker.nullness.qual.NonNull;
+
 /**
  * Version properties maintain a {@code Map<String,String>}; of property k,v pairs along with
  * versioning information metadata.
@@ -46,10 +48,10 @@ import java.util.TreeMap;
  */
 public class VersionedProperties {
 
-  public static final DateTimeFormatter tsFormatter =
+  public static final DateTimeFormatter TIMESTAMP_FORMATTER =
       DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.from(ZoneOffset.UTC));
   // flag value for initialization - on store both the version and next version should be 0.
-  private static final int NO_VERSION = -2;
+  private static final int INIT_VERSION = 0;
   private final int dataVersion;
   private final Instant timestamp;
   private final Map<String,String> props;
@@ -69,7 +71,7 @@ public class VersionedProperties {
    *          been previously validated (if required)
    */
   public VersionedProperties(Map<String,String> props) {
-    this(NO_VERSION, Instant.now(), props);
+    this(INIT_VERSION, Instant.now(), props);
   }
 
   /**
@@ -95,7 +97,7 @@ public class VersionedProperties {
    *
    * @return An unmodifiable view of the property key, value pairs.
    */
-  public Map<String,String> getProperties() {
+  public @NonNull Map<String,String> asMap() {
     return props;
   }
 
@@ -108,22 +110,7 @@ public class VersionedProperties {
    * @return 0 for initial version, otherwise the data version when the properties were serialized.
    */
   public int getDataVersion() {
-    return Math.max(dataVersion, 0);
-  }
-
-  /**
-   * Calculates the version that should be stored when serialized. The serialized version, when
-   * stored, should match the version that will be assigned. This way, data reading the serialized
-   * version can compare the stored version with the node version at any time to detect if the node
-   * version has been updated.
-   * <p>
-   * The initialization of the data version to a negative value allows this value to be calculated
-   * correctly for the first serialization. On the first store, the expected version will be 0.
-   *
-   * @return the next version number that should be serialized, or 0 if this is the initial version.
-   */
-  public int getNextVersion() {
-    return Math.max(dataVersion + 1, 0);
+    return dataVersion;
   }
 
   /**
@@ -142,7 +129,7 @@ public class VersionedProperties {
    * @return a formatted timestamp string.
    */
   public String getTimestampISO() {
-    return tsFormatter.format(timestamp);
+    return TIMESTAMP_FORMATTER.format(timestamp);
   }
 
   /**
@@ -216,7 +203,8 @@ public class VersionedProperties {
 
     sb.append("dataVersion=").append(dataVersion).append(prettyPrint ? "\n" : ", ");
 
-    sb.append("timeStamp=").append(tsFormatter.format(timestamp)).append(prettyPrint ? "\n" : ", ");
+    sb.append("timeStamp=").append(TIMESTAMP_FORMATTER.format(timestamp))
+        .append(prettyPrint ? "\n" : ", ");
 
     Map<String,String> sorted = new TreeMap<>(props);
     sorted.forEach((k, v) -> {
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/NamespacePropKey.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/NamespacePropKey.java
new file mode 100644
index 0000000000..b13fb4d8b8
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/NamespacePropKey.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import static org.apache.accumulo.core.Constants.ZNAMESPACES;
+import static org.apache.accumulo.core.Constants.ZNAMESPACE_CONF;
+import static org.apache.accumulo.core.Constants.ZTABLE_CONF;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public class NamespacePropKey extends PropCacheKey<NamespaceId> {
+
+  private NamespacePropKey(final InstanceId instanceId, final String path, final NamespaceId id) {
+    super(instanceId, path, id);
+  }
+
+  private static String getNodeName(final InstanceId instanceId, final NamespaceId id) {
+    return ZooUtil.getRoot(instanceId) + ZNAMESPACES + "/" + id.canonical() + ZTABLE_CONF + "/"
+        + PROP_NODE_NAME;
+  }
+
+  public static NamespacePropKey of(final ServerContext context, final NamespaceId id) {
+    return of(context.getInstanceID(), id);
+  }
+
+  public static NamespacePropKey of(final InstanceId instanceId, final NamespaceId id) {
+    return new NamespacePropKey(instanceId, getNodePath(instanceId, id), id);
+  }
+
+  private static String getNodePath(final InstanceId instanceId, final NamespaceId id) {
+    return ZooUtil.getRoot(instanceId) + ZNAMESPACES + "/" + id.canonical() + ZNAMESPACE_CONF + "/"
+        + PROP_NODE_NAME;
+  }
+
+  @Override
+  public @NonNull String getNodePath() {
+    return getNodeName(instanceId, id);
+  }
+
+  @Override
+  @NonNull
+  public String getBasePath() {
+    return ZooUtil.getRoot(instanceId) + ZNAMESPACES + "/" + id.canonical() + ZNAMESPACE_CONF;
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropCache.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropCache.java
new file mode 100644
index 0000000000..9d50189a17
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropCache.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+public interface PropCache {
+
+  @Nullable
+  VersionedProperties get(final PropCacheKey<?> propCacheKey);
+
+  void remove(final PropCacheKey<?> propCacheKey);
+
+  void removeAll();
+
+  @Nullable
+  VersionedProperties getWithoutCaching(PropCacheKey<?> propCacheKey);
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropCacheKey.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropCacheKey.java
new file mode 100644
index 0000000000..171a3f8274
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropCacheKey.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import static org.apache.accumulo.core.Constants.ZNAMESPACES;
+import static org.apache.accumulo.core.Constants.ZTABLES;
+
+import java.util.Comparator;
+import java.util.Objects;
+
+import org.apache.accumulo.core.data.AbstractId;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
+import org.apache.accumulo.core.data.TableId;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides a strongly-typed id for storing properties in ZooKeeper. The path in ZooKeeper is
+ * determined by the instance id and the type (system, namespace and table), with different root
+ * paths.
+ * <p>
+ * Provides utility methods from constructing different id based on type and methods to parse a
+ * ZooKeeper path and return a prop cache id.
+ */
+public abstract class PropCacheKey<ID_TYPE extends AbstractId<ID_TYPE>>
+    implements Comparable<PropCacheKey<ID_TYPE>> {
+
+  public static final String PROP_NODE_NAME = "encoded_props";
+
+  private static final Logger log = LoggerFactory.getLogger(PropCacheKey.class);
+
+  // indices for path.split();
+  public static final int TYPE_TOKEN_POSITION = 3;
+  public static final int IID_TOKEN_POSITION = 2;
+  public static final int ID_TOKEN_POSITION = 4;
+
+  // remove starting slash from constant.
+  public static final String TABLES_NODE_NAME = ZTABLES.substring(1);
+  public static final String NAMESPACE_NODE_NAME = ZNAMESPACES.substring(1);
+
+  protected final InstanceId instanceId;
+  protected final ID_TYPE id;
+
+  private final String path;
+
+  protected PropCacheKey(final InstanceId instanceId, final String path, final ID_TYPE id) {
+    this.instanceId = instanceId;
+    this.path = path;
+    this.id = id;
+  }
+
+  public @NonNull String getPath() {
+    return path;
+  }
+
+  public @NonNull abstract String getBasePath();
+
+  public @NonNull abstract String getNodePath();
+
+  public @NonNull ID_TYPE getId() {
+    return id;
+  }
+
+  /**
+   * Determine the prop cache id from a ZooKeeper path
+   *
+   * @param path
+   *          the path
+   * @return the prop cache id
+   */
+  public static @Nullable PropCacheKey<?> fromPath(final String path) {
+    String[] tokens = path.split("/");
+
+    InstanceId instanceId;
+    try {
+      instanceId = InstanceId.of(tokens[IID_TOKEN_POSITION]);
+    } catch (ArrayIndexOutOfBoundsException ex) {
+      log.warn("Path '{}' is an invalid path for a property cache key", path);
+      return null;
+    }
+    if (tokens.length < 1 || !tokens[tokens.length - 1].equals(PROP_NODE_NAME)) {
+      // without tokens or it does not end with PROP_NAME_NAME
+      return null;
+    }
+    if (tokens[TYPE_TOKEN_POSITION].equals(TABLES_NODE_NAME)) {
+      return TablePropKey.of(instanceId, TableId.of(tokens[ID_TOKEN_POSITION]));
+    }
+    if (tokens[TYPE_TOKEN_POSITION].equals(NAMESPACE_NODE_NAME)) {
+      return NamespacePropKey.of(instanceId, NamespaceId.of(tokens[ID_TOKEN_POSITION]));
+    }
+    return SystemPropKey.of(instanceId);
+  }
+
+  @Override
+  public int compareTo(@NonNull PropCacheKey<ID_TYPE> other) {
+    return Comparator.comparing(PropCacheKey<ID_TYPE>::getPath).compare(this, other);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o)
+      return true;
+    if (o == null || getClass() != o.getClass())
+      return false;
+    PropCacheKey<?> that = (PropCacheKey<?>) o;
+    if (getId().getClass() != that.getId().getClass()) {
+      return false;
+    }
+    return path.equals(that.path);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(path);
+  }
+
+  @Override
+  public String toString() {
+    return this.getClass().getSimpleName() + "{" + getId().getClass().getSimpleName() + "="
+        + getId().canonical() + "'}";
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropChangeListener.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropChangeListener.java
new file mode 100644
index 0000000000..4e4b010cf2
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropChangeListener.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+public interface PropChangeListener {
+
+  /**
+   * Signal that a ZooKeeper data change event occurred and that the data has changed.
+   */
+  void zkChangeEvent(final PropCacheKey<?> propCacheKey);
+
+  /**
+   * Signal that a cache change event occurred - cache change events occur on eviction or
+   * invalidation of the cache entry. The underlying data may or may not have changed.
+   */
+  void cacheChangeEvent(final PropCacheKey<?> propCacheKey);
+
+  /**
+   * Signal that the node had been deleted from ZooKeeper.
+   */
+  void deleteEvent(final PropCacheKey<?> propCacheKey);
+
+  /**
+   * A ZooKeeper connection event (session closed, expired...) and that
+   */
+  void connectionEvent();
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java
new file mode 100644
index 0000000000..8315c22c33
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import java.util.Collection;
+import java.util.Map;
+
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public interface PropStore {
+
+  /**
+   * Test that a node for properties exists without throwing a KeeperException.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @return true if the property node exists, false otherwise.
+   * @throws IllegalStateException
+   *           if the check fails due to interrupt.
+   */
+  boolean exists(PropCacheKey<?> propCacheKey);
+
+  /**
+   * Create an initial entry for the PropCacheId. If properties already exist, they are not
+   * modified.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @param props
+   *          a map of property k,v pairs
+   * @throws IllegalStateException
+   *           if the updates fails because of an underlying store exception
+   */
+  void create(PropCacheKey<?> propCacheKey, Map<String,String> props);
+
+  /**
+   *
+   * @param propCacheId
+   *          the prop cache key
+   * @return The versioned properties.
+   * @throws IllegalStateException
+   *           if the updates fails because of an underlying store exception or if the properties do
+   *           not exist for the propCacheId
+   */
+  @NonNull
+  VersionedProperties get(PropCacheKey<?> propCacheId);
+
+  /**
+   * Adds or updates current properties. If the property currently exists it is overwritten,
+   * otherwise it is added.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @param props
+   *          a map of property k,v pairs
+   * @throws IllegalStateException
+   *           if the values cannot be written or if an underlying store exception occurs.
+   */
+  void putAll(PropCacheKey<?> propCacheKey, Map<String,String> props);
+
+  /**
+   * Delete the store node from the underlying store.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @throws IllegalStateException
+   *           if the updates fails because of an underlying store exception
+   */
+  void delete(PropCacheKey<?> propCacheKey);
+
+  /**
+   * Deletes individual properties specified by the set of keys.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @param keys
+   *          a set of keys.
+   * @throws IllegalStateException
+   *           if the values cannot be deleted or if an underlying store exception occurs.
+   */
+  void removeProperties(PropCacheKey<?> propCacheKey, Collection<String> keys);
+
+  /**
+   * External processes can register for notifications if the properties change. Normally processes
+   * can read from the store and always receive the current snapshot of the latest values. However,
+   * it the process wants to take an active action on change detections, then they can register and
+   * receive notifications.
+   * <p>
+   * Implementation detail - the notification occurs on a separate thread from the underlying store,
+   * but listeners should not perform lengthy operations on the notification to prevent delaying
+   * other listeners from receive timely notification of the changes detected.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @param listener
+   *          a listener
+   */
+  void registerAsListener(PropCacheKey<?> propCacheKey, PropChangeListener listener);
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/SystemPropKey.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/SystemPropKey.java
new file mode 100644
index 0000000000..f960f4a242
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/SystemPropKey.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import static org.apache.accumulo.core.Constants.ZCONFIG;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public class SystemPropKey extends PropCacheKey<InstanceId> {
+
+  private SystemPropKey(final InstanceId instanceId, final String path) {
+    super(instanceId, path, instanceId);
+  }
+
+  @Override
+  public @NonNull String getNodePath() {
+    return getNodeName(instanceId);
+  }
+
+  @Override
+  public @NonNull String getBasePath() {
+    return ZooUtil.getRoot(instanceId) + ZCONFIG;
+  }
+
+  public static SystemPropKey of(final ServerContext context) {
+    return of(context.getInstanceID());
+  }
+
+  public static SystemPropKey of(final InstanceId instanceId) {
+    return new SystemPropKey(instanceId, getNodeName(instanceId));
+  }
+
+  private static String getNodeName(final InstanceId instanceId) {
+    return ZooUtil.getRoot(instanceId) + ZCONFIG + "/" + PROP_NODE_NAME;
+  }
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/TablePropKey.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/TablePropKey.java
new file mode 100644
index 0000000000..d8ee686ca3
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/TablePropKey.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import static org.apache.accumulo.core.Constants.ZTABLES;
+import static org.apache.accumulo.core.Constants.ZTABLE_CONF;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public class TablePropKey extends PropCacheKey<TableId> {
+
+  public static TablePropKey of(final ServerContext context, final TableId tableId) {
+    return of(context.getInstanceID(), tableId);
+  }
+
+  public static TablePropKey of(final InstanceId instanceId, final TableId tableId) {
+    return new TablePropKey(instanceId, getNodePath(instanceId, tableId), tableId);
+  }
+
+  private TablePropKey(final InstanceId instanceId, final String path, final TableId tableId) {
+    super(instanceId, path, tableId);
+  }
+
+  private static String getNodePath(final InstanceId instanceId, final TableId id) {
+    return ZooUtil.getRoot(instanceId) + ZTABLES + "/" + id.canonical() + ZTABLE_CONF + "/"
+        + PROP_NODE_NAME;
+  }
+
+  @Override
+  public @NonNull String getNodePath() {
+    return getNodePath(instanceId, id);
+  }
+
+  @Override
+  public @NonNull String getBasePath() {
+    return ZooUtil.getRoot(instanceId) + ZTABLES + "/" + id.canonical() + ZTABLE_CONF;
+  }
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropCacheCaffeineImpl.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropCacheCaffeineImpl.java
new file mode 100644
index 0000000000..d1664b8fcb
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropCacheCaffeineImpl.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.accumulo.core.util.threads.ThreadPools;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCache;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.CacheLoader;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.github.benmanes.caffeine.cache.Ticker;
+import com.google.common.annotations.VisibleForTesting;
+
+public class PropCacheCaffeineImpl implements PropCache {
+
+  public static final TimeUnit BASE_TIME_UNITS = TimeUnit.MINUTES;
+  public static final int REFRESH_MIN = 15;
+  public static final int EXPIRE_MIN = 60;
+  private static final Logger log = LoggerFactory.getLogger(PropCacheCaffeineImpl.class);
+  private static final Executor executor = ThreadPools.getServerThreadPools().createThreadPool(1,
+      20, 60, TimeUnit.SECONDS, "cache-refresh", false);
+
+  private final PropStoreMetrics metrics;
+
+  private final LoadingCache<PropCacheKey<?>,VersionedProperties> cache;
+
+  private PropCacheCaffeineImpl(final CacheLoader<PropCacheKey<?>,VersionedProperties> cacheLoader,
+      final PropStoreMetrics metrics, final Ticker ticker) {
+    this.metrics = metrics;
+    var builder = Caffeine.newBuilder().refreshAfterWrite(REFRESH_MIN, BASE_TIME_UNITS)
+        .expireAfterAccess(EXPIRE_MIN, BASE_TIME_UNITS).evictionListener(this::evictionNotifier)
+        .executor(executor);
+    if (ticker != null) {
+      builder = builder.ticker(ticker);
+    }
+    cache = builder.build(cacheLoader);
+  }
+
+  public PropStoreMetrics getMetrics() {
+    return metrics;
+  }
+
+  void evictionNotifier(PropCacheKey<?> propCacheKey, VersionedProperties value,
+      RemovalCause cause) {
+    log.trace("Evicted: ID: {} was evicted from cache. Reason: {}", propCacheKey, cause);
+    metrics.incrEviction();
+  }
+
+  @Override
+  public @Nullable VersionedProperties get(PropCacheKey<?> propCacheKey) {
+    log.trace("Called get() for {}", propCacheKey);
+    try {
+      return cache.get(propCacheKey);
+    } catch (Exception ex) {
+      log.info("Cache failed to retrieve properties for: " + propCacheKey, ex);
+      metrics.incrZkError();
+      return null;
+    }
+  }
+
+  @Override
+  public void remove(PropCacheKey<?> propCacheKey) {
+    log.trace("clear {} from cache", propCacheKey);
+    cache.invalidate(propCacheKey);
+  }
+
+  @Override
+  public void removeAll() {
+    cache.invalidateAll();
+  }
+
+  /** for testing - force Caffeine housekeeping to run. */
+  @VisibleForTesting
+  void cleanUp() {
+    cache.cleanUp();
+  }
+
+  /**
+   * Retrieve the version properties if present in the cache, otherwise return null. This prevents
+   * caching the properties and should be used when properties will be updated and then committed to
+   * the backend store. The process that is updating the values may not need them for additional
+   * processing so there is no reason to store them in the cache at this time. If they are used, a
+   * normal cache get will load the property into the cache.
+   *
+   * @param propCacheKey
+   *          the property id
+   * @return the version properties if cached, otherwise return null.
+   */
+  @Override
+  public @Nullable VersionedProperties getWithoutCaching(PropCacheKey<?> propCacheKey) {
+    return cache.getIfPresent(propCacheKey);
+  }
+
+  public static class Builder {
+
+    private final PropStoreMetrics metrics;
+    private final ZooPropLoader zooPropLoader;
+    private Ticker ticker = null;
+
+    public Builder(final ZooPropLoader zooPropLoader, final PropStoreMetrics metrics) {
+      Objects.requireNonNull(zooPropLoader, "A PropStoreChangeMonitor must be provided");
+      this.zooPropLoader = zooPropLoader;
+      this.metrics = metrics;
+    }
+
+    public PropCacheCaffeineImpl build() {
+      return new PropCacheCaffeineImpl(zooPropLoader, metrics, ticker);
+    }
+
+    public Builder withTicker(final Ticker ticker) {
+      this.ticker = ticker;
+      return this;
+    }
+  }
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreEventTask.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreEventTask.java
new file mode 100644
index 0000000000..c87e4e8807
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreEventTask.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import java.util.Set;
+
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.PropChangeListener;
+
+/**
+ * Provides a simple runnable base task for notifying listeners for PropStore event change
+ * notifications.
+ */
+public abstract class PropStoreEventTask implements Runnable {
+
+  private final PropCacheKey<?> propCacheKey;
+  private final Set<PropChangeListener> listeners;
+
+  /**
+   * Used when the notification is sent to the listeners without specifying a prop cache key.
+   *
+   * @param listeners
+   *          the set of listeners.
+   */
+  private PropStoreEventTask(final Set<PropChangeListener> listeners) {
+    this.propCacheKey = null;
+    this.listeners = listeners;
+  }
+
+  /**
+   * Used when listeners for the specified prop cahe key should receive a notification.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @param listeners
+   *          the set of listeners
+   */
+  private PropStoreEventTask(final PropCacheKey<?> propCacheKey,
+      final Set<PropChangeListener> listeners) {
+    this.propCacheKey = propCacheKey;
+    this.listeners = listeners;
+  }
+
+  public static class PropStoreZkChangeEventTask extends PropStoreEventTask {
+
+    PropStoreZkChangeEventTask(final PropCacheKey<?> propCacheKey,
+        final Set<PropChangeListener> listeners) {
+      super(propCacheKey, listeners);
+    }
+
+    @Override
+    public void run() {
+      super.listeners.forEach(listener -> listener.zkChangeEvent(super.propCacheKey));
+    }
+  }
+
+  public static class PropStoreCacheChangeEventTask extends PropStoreEventTask {
+
+    PropStoreCacheChangeEventTask(final PropCacheKey<?> propCacheKey,
+        final Set<PropChangeListener> listeners) {
+      super(propCacheKey, listeners);
+    }
+
+    @Override
+    public void run() {
+      super.listeners.forEach(listener -> listener.cacheChangeEvent(super.propCacheKey));
+    }
+  }
+
+  public static class PropStoreDeleteEventTask extends PropStoreEventTask {
+
+    PropStoreDeleteEventTask(final PropCacheKey<?> propCacheKey,
+        final Set<PropChangeListener> listeners) {
+      super(propCacheKey, listeners);
+    }
+
+    @Override
+    public void run() {
+      super.listeners.forEach(listener -> listener.deleteEvent(super.propCacheKey));
+    }
+  }
+
+  public static class PropStoreConnectionEventTask extends PropStoreEventTask {
+
+    PropStoreConnectionEventTask(final Set<PropChangeListener> listeners) {
+      super(listeners);
+    }
+
+    @Override
+    public void run() {
+      super.listeners.forEach(PropChangeListener::connectionEvent);
+    }
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreMetrics.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreMetrics.java
new file mode 100644
index 0000000000..26e030d343
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreMetrics.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.accumulo.core.metrics.MetricsProducer;
+import org.apache.accumulo.core.metrics.MetricsUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+
+public class PropStoreMetrics implements MetricsProducer {
+
+  private static final Logger log = LoggerFactory.getLogger(PropStoreMetrics.class);
+
+  private Timer load;
+  private Counter refresh;
+  private Counter refreshLoad;
+  private Counter eviction;
+  private Counter zkError;
+
+  @Override
+  public void registerMetrics(MeterRegistry registry) {
+
+    load = Timer.builder(METRICS_PROPSTORE_LOAD_TIMER).description("prop store load time")
+        .tags(MetricsUtil.getCommonTags()).register(registry);
+
+    refresh =
+        Counter.builder(METRICS_PROPSTORE_REFRESH_COUNT).description("prop store refresh count")
+            .tags(MetricsUtil.getCommonTags()).register(registry);
+
+    refreshLoad = Counter.builder(METRICS_PROPSTORE_REFRESH_LOAD_COUNT)
+        .description("prop store refresh load count").tags(MetricsUtil.getCommonTags())
+        .register(registry);
+
+    eviction =
+        Counter.builder(METRICS_PROPSTORE_EVICTION_COUNT).description("prop store eviction count")
+            .tags(MetricsUtil.getCommonTags()).register(registry);
+
+    zkError = Counter.builder(METRICS_PROPSTORE_ZK_ERROR_COUNT)
+        .description("prop store ZooKeeper error count").tags(MetricsUtil.getCommonTags())
+        .register(registry);
+
+  }
+
+  public PropStoreMetrics() {
+    log.debug("Creating PropStore metrics");
+  }
+
+  public void addLoadTime(final long value) {
+    log.trace("Load time: {}", value);
+    load.record(Duration.ofMillis(value));
+    log.trace("Load count: {} time:{}", load.count(), load.totalTime(TimeUnit.MILLISECONDS));
+  }
+
+  public void incrRefresh() {
+    refresh.increment();
+  }
+
+  public void incrRefreshLoad() {
+    refreshLoad.increment();
+  }
+
+  public void incrEviction() {
+    eviction.increment();
+  }
+
+  public void incrZkError() {
+    zkError.increment();
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreWatcher.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreWatcher.java
new file mode 100644
index 0000000000..5e8a6e7ab0
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/PropStoreWatcher.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toSet;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.apache.accumulo.core.util.threads.ThreadPools;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.PropChangeListener;
+import org.apache.zookeeper.WatchedEvent;
+import org.apache.zookeeper.Watcher;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class serves as a translator between ZooKeeper events and converts them to PropStore events.
+ * Using this as an intermediary, the external listeners do not need to set / manage external
+ * ZooKeeper watchers, they can register for PropStore events if they need to take active action on
+ * change detection.
+ * <p>
+ * Users of the PropStore.get() will get properties that match what is stored in ZooKeeper for each
+ * call and do not need to manage any caching. However, the ability to receive active notification
+ * without needed to register / manage ZooKeeper watchers external to the PropStore is provided in
+ * case other code is relying on active notifications.
+ * <p>
+ * The notification occurs on a separate thread from the ZooKeeper notification handling, but
+ * listeners should not perform lengthy operations on the notification thread so that other listener
+ * notifications are not delayed.
+ */
+public class PropStoreWatcher implements Watcher {
+
+  private static final Logger log = LoggerFactory.getLogger(PropStoreWatcher.class);
+
+  private static final ExecutorService executorService =
+      ThreadPools.getServerThreadPools().createFixedThreadPool(2, "zoo_change_update", false);
+
+  private final ReentrantReadWriteLock listenerLock = new ReentrantReadWriteLock();
+  private final ReentrantReadWriteLock.ReadLock listenerReadLock = listenerLock.readLock();
+  private final ReentrantReadWriteLock.WriteLock listenerWriteLock = listenerLock.writeLock();
+
+  // access should be guarded by acquiring the listener read or write lock
+  private final Map<PropCacheKey<?>,Set<PropChangeListener>> listeners = new HashMap<>();
+
+  private final ReadyMonitor zkReadyMonitor;
+
+  public PropStoreWatcher(final ReadyMonitor zkReadyMonitor) {
+    this.zkReadyMonitor = zkReadyMonitor;
+  }
+
+  public void registerListener(final PropCacheKey<?> propCacheKey,
+      final PropChangeListener listener) {
+    listenerWriteLock.lock();
+    try {
+      Set<PropChangeListener> set = listeners.computeIfAbsent(propCacheKey, s -> new HashSet<>());
+      set.add(listener);
+    } finally {
+      listenerWriteLock.unlock();
+    }
+  }
+
+  /**
+   * Process a ZooKeeper event. This method does not reset the watcher. Subscribers are notified of
+   * the change - if they call get to update and respond to the change the watcher will be (re)set
+   * then. This helps clean up watchers by not automatically re-adding the watcher on the event but
+   * only if being used.
+   *
+   * @param event
+   *          ZooKeeper event.
+   */
+  @Override
+  public void process(final WatchedEvent event) {
+
+    String path;
+    PropCacheKey<?> propCacheKey;
+    switch (event.getType()) {
+      case NodeDataChanged:
+        path = event.getPath();
+        log.trace("handle change event for path: {}", path);
+        propCacheKey = PropCacheKey.fromPath(path);
+        if (propCacheKey != null) {
+          signalZkChangeEvent(propCacheKey);
+        }
+        break;
+      case NodeDeleted:
+        path = event.getPath();
+        log.trace("handle delete event for path: {}", path);
+        propCacheKey = PropCacheKey.fromPath(path);
+        if (propCacheKey != null) {
+          // notify listeners
+          Set<PropChangeListener> snapshot = getListenerSnapshot(propCacheKey);
+          if (snapshot != null) {
+            executorService
+                .execute(new PropStoreEventTask.PropStoreDeleteEventTask(propCacheKey, snapshot));
+          }
+          listenerCleanup(propCacheKey);
+        }
+        break;
+      case None:
+        Event.KeeperState state = event.getState();
+        switch (state) {
+          // pause - could reconnect
+          case ConnectedReadOnly:
+          case Disconnected:
+            log.debug("ZooKeeper disconnected event received");
+            zkReadyMonitor.clearReady();
+            executorService.execute(
+                new PropStoreEventTask.PropStoreConnectionEventTask(getAllListenersSnapshot()));
+            break;
+
+          // okay
+          case SyncConnected:
+            log.debug("ZooKeeper connected event received");
+            zkReadyMonitor.setReady();
+            break;
+
+          // terminal - never coming back.
+          case Expired:
+          case Closed:
+            log.info("ZooKeeper connection closed event received");
+            zkReadyMonitor.clearReady();
+            zkReadyMonitor.setClosed(); // terminal condition
+            executorService.execute(
+                new PropStoreEventTask.PropStoreConnectionEventTask(getAllListenersSnapshot()));
+            break;
+
+          default:
+            log.trace("ignoring zooKeeper state: {}", state);
+        }
+        break;
+      default:
+        break;
+    }
+
+  }
+
+  /**
+   * Execute a task to notify registered listeners that the propCacheKey node received an event
+   * notification from ZooKeeper and should be updated. The process can be initiated either by a
+   * ZooKeeper notification or a change detected in the cache based on a ZooKeeper event.
+   *
+   * @param propCacheKey
+   *          the cache id
+   */
+  public void signalZkChangeEvent(@NonNull final PropCacheKey<?> propCacheKey) {
+    log.trace("signal ZooKeeper change event: {}", propCacheKey);
+    Set<PropChangeListener> snapshot = getListenerSnapshot(propCacheKey);
+    log.trace("Sending change event to: {}", snapshot);
+    if (snapshot != null) {
+      executorService
+          .execute(new PropStoreEventTask.PropStoreZkChangeEventTask(propCacheKey, snapshot));
+    }
+  }
+
+  /**
+   * Execute a task to notify registered listeners that the propCacheKey node change was detected
+   * should be updated.
+   *
+   * @param propCacheKey
+   *          the cache id
+   */
+  public void signalCacheChangeEvent(final PropCacheKey<?> propCacheKey) {
+    log.trace("cache change event: {}", propCacheKey);
+    Set<PropChangeListener> snapshot = getListenerSnapshot(propCacheKey);
+    if (snapshot != null) {
+      executorService
+          .execute(new PropStoreEventTask.PropStoreCacheChangeEventTask(propCacheKey, snapshot));
+    }
+  }
+
+  /**
+   * Clean-up the active listeners set when an entry is removed from the cache, remove it from the
+   * active listeners.
+   *
+   * @param propCacheKey
+   *          the cache id
+   */
+  public void listenerCleanup(final PropCacheKey<?> propCacheKey) {
+    listenerWriteLock.lock();
+    try {
+      listeners.remove(propCacheKey);
+    } finally {
+      listenerWriteLock.unlock();
+    }
+  }
+
+  /**
+   * Get an immutable snapshot of the listeners for a prop cache id. The set is intended for
+   * notification of changes for a specific prop cache id.
+   *
+   * @param propCacheKey
+   *          the prop cache id
+   * @return an immutable copy of listeners.
+   */
+  private Set<PropChangeListener> getListenerSnapshot(final PropCacheKey<?> propCacheKey) {
+
+    Set<PropChangeListener> snapshot = null;
+    listenerReadLock.lock();
+    try {
+      Set<PropChangeListener> set = listeners.get(propCacheKey);
+      if (set != null) {
+        snapshot = Set.copyOf(set);
+      }
+
+    } finally {
+      listenerReadLock.unlock();
+    }
+    return snapshot;
+  }
+
+  /**
+   * Get an immutable snapshot of the all listeners registered for event. The set is intended for
+   * connection event notifications that are not specific to an individual prop cache id.
+   *
+   * @return an immutable copy of all registered listeners.
+   */
+  private Set<PropChangeListener> getAllListenersSnapshot() {
+
+    listenerReadLock.lock();
+    try {
+      return listeners.keySet().stream().flatMap(key -> listeners.get(key).stream())
+          .collect(collectingAndThen(toSet(), Collections::unmodifiableSet));
+    } finally {
+      listenerReadLock.unlock();
+    }
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ReadyMonitor.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ReadyMonitor.java
new file mode 100644
index 0000000000..84fabc72f9
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ReadyMonitor.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import java.util.StringJoiner;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Provides a barrier to block operations until a resource signals that it is ready. This class
+ * encapsulates a wait / notify operation using a lock and condition. Instances are initialized with
+ * the resource unavailable - call {@link #setReady()} when the resource is available
+ */
+public class ReadyMonitor {
+
+  private final String resourceName;
+  private final long timeout;
+
+  // used to preserve memory ordering consistency (volatile semantics)
+  private final AtomicBoolean haveConnection = new AtomicBoolean(false);
+  private final AtomicBoolean isClosed = new AtomicBoolean(false);
+
+  private final ReentrantLock lock = new ReentrantLock();
+  private final Condition readySignal = lock.newCondition();
+
+  /**
+   * Create an instance of a ready monitor.
+   *
+   * @param resourceName
+   *          the resource name guarded by this monitor (used by logging)
+   * @param timeout
+   *          the max time in milliseconds this will block waiting.
+   */
+  public ReadyMonitor(final String resourceName, final long timeout) {
+    this.resourceName = resourceName;
+    this.timeout = timeout;
+  }
+
+  /**
+   * Non-blocking call to get ready state. It will throw is the ZooKeeper connection has closed
+   * (non-recoverable condition)
+   *
+   * @return true if resource is ready, false otherwise
+   */
+  public boolean test() {
+    if (isClosed.get()) {
+      throw new IllegalStateException("ZooKeeper has closed the connection - cannot continue");
+    }
+    return haveConnection.get();
+  }
+
+  /**
+   * Method blocks until the resource is ready. If the resource does not become ready within the
+   * timeout an IllegalStateException is thrown.
+   *
+   * @throws IllegalStateException
+   *           if the resource does not signal ready withing timeout.
+   */
+  public void isReady() {
+
+    if (test()) {
+      return;
+    }
+
+    // loop handles a spurious notification
+    while (!haveConnection.get()) {
+      try {
+        lock.lock();
+        if (!readySignal.await(timeout, TimeUnit.MILLISECONDS)) {
+          var msg = resourceName + " failed to be ready within " + timeout + " ms";
+          throw new IllegalStateException(msg);
+        }
+        // re-signal in case notification missed
+        readySignal.signalAll();
+      } catch (InterruptedException ex) {
+        Thread.currentThread().interrupt();
+        throw new IllegalStateException(resourceName + " interrupted while waiting until ready ",
+            ex);
+      } finally {
+        lock.unlock();
+      }
+    }
+  }
+
+  /**
+   * ZooKeeper has closed the connection. This is a terminal state that cannot be recoved from
+   * without creating a new ZooKeeper client.
+   */
+  public void setClosed() {
+    isClosed.set(true);
+  }
+
+  /**
+   * Indicate that the resource is ready.
+   */
+  public void setReady() {
+
+    // already set - noop
+    if (test()) {
+      return;
+    }
+
+    try {
+      lock.lock();
+      haveConnection.set(true);
+      readySignal.signalAll();
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  /**
+   * Indicate that resource is NOT available and that calls to {@link #isReady()} should block.
+   */
+  public void clearReady() {
+    haveConnection.set(false);
+  }
+
+  @Override
+  public String toString() {
+    return new StringJoiner(", ", ReadyMonitor.class.getSimpleName() + "[", "]")
+        .add("resourceName='" + resourceName + "'").add("timeout=" + timeout)
+        .add("haveConnection=" + haveConnection).toString();
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropLoader.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropLoader.java
new file mode 100644
index 0000000000..4706b132bd
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropLoader.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.CacheLoader;
+
+public class ZooPropLoader implements CacheLoader<PropCacheKey<?>,VersionedProperties> {
+
+  private static final Logger log = LoggerFactory.getLogger(ZooPropLoader.class);
+
+  private final ZooReaderWriter zrw;
+  private final VersionedPropCodec propCodec;
+  // used to set watcher, does not react to events.
+  private final PropStoreWatcher propStoreWatcher;
+  private final PropStoreMetrics metrics;
+
+  public ZooPropLoader(final ZooReaderWriter zrw, final VersionedPropCodec propCodec,
+      final PropStoreWatcher propStoreWatcher, final PropStoreMetrics metrics) {
+    this.zrw = zrw;
+    this.propCodec = propCodec;
+    this.propStoreWatcher = propStoreWatcher;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public @Nullable VersionedProperties load(PropCacheKey<?> propCacheKey) {
+    try {
+      log.trace("load called for {}", propCacheKey);
+
+      long startNanos = System.nanoTime();
+
+      Stat stat = new Stat();
+      byte[] bytes = zrw.getData(propCacheKey.getPath(), propStoreWatcher, stat);
+      var vProps = propCodec.fromBytes(stat.getVersion(), bytes);
+
+      metrics.addLoadTime(
+          TimeUnit.MILLISECONDS.convert(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS));
+
+      return vProps;
+    } catch (KeeperException.NoNodeException ex) {
+      metrics.incrZkError();
+      log.debug("property node for {} does not exist - it may be being created", propCacheKey);
+      propStoreWatcher.signalZkChangeEvent(propCacheKey);
+      return null;
+    } catch (Exception ex) {
+      metrics.incrZkError();
+      log.info("Failed to load properties for: {} from ZooKeeper, returning null", propCacheKey,
+          ex);
+      propStoreWatcher.signalZkChangeEvent(propCacheKey);
+      return null;
+    }
+  }
+
+  @Override
+  public CompletableFuture<? extends VersionedProperties> asyncLoad(PropCacheKey<?> propCacheKey,
+      Executor executor) throws Exception {
+    log.trace("asyncLoad called for key: {}", propCacheKey);
+    return CacheLoader.super.asyncLoad(propCacheKey, executor);
+  }
+
+  @Override
+  public CompletableFuture<? extends VersionedProperties> asyncReload(PropCacheKey<?> propCacheKey,
+      VersionedProperties oldValue, Executor executor) throws Exception {
+    log.trace("asyncReload called for key: {}", propCacheKey);
+    metrics.incrRefresh();
+
+    return CompletableFuture.supplyAsync(() -> loadIfDifferentVersion(propCacheKey, oldValue),
+        executor);
+  }
+
+  @Override
+  public @Nullable VersionedProperties reload(PropCacheKey<?> propCacheKey,
+      VersionedProperties oldValue) throws Exception {
+    log.trace("reload called for: {}", propCacheKey);
+    metrics.incrRefresh();
+    return loadIfDifferentVersion(propCacheKey, oldValue);
+  }
+
+  /**
+   * First checks that the ZooKeeper data version matches the versioned property's data version,
+   * using a lighter weight ZooKeeper call to get the Stat structure. If the data versions match,
+   * the original version properties are returned without additional ZooKeeper calls. If the
+   * versions do not match, the data is reread from ZooKeeper and returned. If any exceptions occur,
+   * a null value is returned and any cached values should be considered invalid.
+   *
+   * @param propCacheId
+   *          the property cache id
+   * @param currentValue
+   *          the current versioned properties.
+   * @return versioned properties that match the values stored in ZooKeeper, or null if the
+   *         properties cannot be retrieved.
+   */
+  private @Nullable VersionedProperties loadIfDifferentVersion(PropCacheKey<?> propCacheId,
+      VersionedProperties currentValue) {
+    requireNonNull(propCacheId, "propCacheId cannot be null");
+    try {
+      Stat stat = zrw.getStatus(propCacheId.getPath());
+
+      log.trace("Check stat version on reload. Zk: {}, Cache: {}", stat.getVersion(),
+          currentValue.getDataVersion());
+      // no change, cached version is valid.
+      if (stat.getVersion() == currentValue.getDataVersion()) {
+        return currentValue;
+      }
+
+      log.trace("different version in cache for {}, calling loader to get update from ZooKeeper",
+          propCacheId);
+
+      var updatedValue = load(propCacheId);
+
+      metrics.incrRefreshLoad();
+
+      // The cache will be updated - notify external listeners value changed.
+      propStoreWatcher.signalCacheChangeEvent(propCacheId);
+      log.trace("Updated value {}", updatedValue == null ? "null" : updatedValue.print(true));
+      return updatedValue;
+    } catch (RuntimeException | KeeperException | InterruptedException ex) {
+      log.warn("async exception occurred reading properties from ZooKeeper for: {} returning null",
+          propCacheId, ex);
+      metrics.incrZkError();
+      propStoreWatcher.signalZkChangeEvent(propCacheId);
+      return null;
+    }
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java
new file mode 100644
index 0000000000..7c522e0e2f
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java
@@ -0,0 +1,390 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static java.util.Objects.requireNonNullElseGet;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BiFunction;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.metrics.MetricsUtil;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCache;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.PropChangeListener;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.util.ConfigTransformer;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.Ticker;
+import com.google.common.annotations.VisibleForTesting;
+
+public class ZooPropStore implements PropStore, PropChangeListener {
+
+  private final static Logger log = LoggerFactory.getLogger(ZooPropStore.class);
+  private final static VersionedPropCodec codec = VersionedPropCodec.getDefault();
+
+  private final ZooReaderWriter zrw;
+  private final PropStoreWatcher propStoreWatcher;
+  private final PropCache cache;
+  private final PropStoreMetrics cacheMetrics = new PropStoreMetrics();
+  private final ReadyMonitor zkReadyMon;
+
+  /**
+   * Create instance using ZooPropStore.Builder
+   *
+   * @param instanceId
+   *          the instance id
+   * @param zrw
+   *          a wrapper set of utilities for accessing ZooKeeper.
+   */
+  private ZooPropStore(final InstanceId instanceId, final ZooReaderWriter zrw) {
+    this(instanceId, zrw, null, null, null);
+  }
+
+  /**
+   * For testing create an instance with the optionally pass synthetic clock (Ticker), a
+   * ReadyMonitor and a PropStore watcher allowing them to be mocked. If the optional components are
+   * passed as null an internal instance is created.
+   *
+   * @param instanceId
+   *          the instance id
+   * @param zrw
+   *          a wrapper set of utilities for accessing ZooKeeper.
+   * @param monitor
+   *          a ready monitor. Optional, if null, one is created.
+   * @param watcher
+   *          a watcher. Optional, if null, one is created.
+   * @param ticker
+   *          a synthetic clock used for testing. Optional, if null, one is created.
+   */
+  ZooPropStore(final InstanceId instanceId, final ZooReaderWriter zrw, final ReadyMonitor monitor,
+      final PropStoreWatcher watcher, final Ticker ticker) {
+
+    this.zrw = zrw;
+
+    this.zkReadyMon = requireNonNullElseGet(monitor,
+        () -> new ReadyMonitor("prop-store", Math.round(zrw.getSessionTimeout() * 1.75)));
+
+    this.propStoreWatcher = requireNonNullElseGet(watcher, () -> new PropStoreWatcher(zkReadyMon));
+
+    ZooPropLoader propLoader = new ZooPropLoader(zrw, codec, this.propStoreWatcher, cacheMetrics);
+
+    if (ticker == null) {
+      this.cache = new PropCacheCaffeineImpl.Builder(propLoader, cacheMetrics).build();
+    } else {
+      this.cache =
+          new PropCacheCaffeineImpl.Builder(propLoader, cacheMetrics).withTicker(ticker).build();
+    }
+
+    MetricsUtil.initializeProducers(cacheMetrics);
+
+    try {
+      var path = ZooUtil.getRoot(instanceId);
+      if (zrw.exists(path, propStoreWatcher)) {
+        log.debug("Have a ZooKeeper connection and found instance node: {}", instanceId);
+        zkReadyMon.setReady();
+      } else {
+        throw new IllegalStateException("Instance may not have been initialized, root node: " + path
+            + " does not exist in ZooKeeper");
+      }
+    } catch (InterruptedException | KeeperException ex) {
+      throw new IllegalStateException("Failed to read root node " + instanceId + " from ZooKeeper",
+          ex);
+    }
+  }
+
+  public static PropStore initialize(@NonNull final InstanceId instanceId,
+      @NonNull final ZooReaderWriter zrw) {
+    return new ZooPropStore(instanceId, zrw);
+  }
+
+  @Override
+  public boolean exists(final PropCacheKey<?> propCacheKey) {
+    try {
+      if (zrw.exists(propCacheKey.getPath())) {
+        return true;
+      }
+    } catch (KeeperException ex) {
+      // ignore Keeper exception on check.
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("Interrupted testing if node exists", ex);
+    }
+    return false;
+  }
+
+  public PropStoreMetrics getMetrics() {
+    return cacheMetrics;
+  }
+
+  @Override
+  public void create(PropCacheKey<?> propCacheKey, Map<String,String> props) {
+
+    try {
+      VersionedProperties vProps = new VersionedProperties(props);
+      String path = propCacheKey.getPath();
+      zrw.putPersistentData(path, codec.toBytes(vProps), ZooUtil.NodeExistsPolicy.FAIL);
+    } catch (IOException | KeeperException | InterruptedException ex) {
+      throw new IllegalStateException("Failed to serialize properties for " + propCacheKey, ex);
+    }
+  }
+
+  /**
+   * get or create properties from the store. If the property node does not exist in ZooKeeper,
+   * legacy properties exist, they will be converted to the new storage form and naming convention.
+   * The legacy properties are deleted once the new node format is written.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @return The versioned properties.
+   * @throws IllegalStateException
+   *           if the updates fails because of an underlying store exception
+   */
+  @Override
+  public @NonNull VersionedProperties get(final PropCacheKey<?> propCacheKey) {
+    checkZkConnection(); // if ZK not connected, block, do not just return a cached value.
+    propStoreWatcher.registerListener(propCacheKey, this);
+
+    var props = cache.get(propCacheKey);
+    if (props != null) {
+      return props;
+    }
+
+    if (propCacheKey instanceof SystemPropKey) {
+      return new ConfigTransformer(zrw, codec, propStoreWatcher).transform(propCacheKey);
+    }
+
+    throw new IllegalStateException(
+        "Invalid request for " + propCacheKey + ", the property node does not exist");
+  }
+
+  /**
+   * Convenience method for utilities that may not have a PropStore read the encoded properties
+   * directly from ZooKeeper. This allows utilities access when there is a ZooKeeper, may there may
+   * not be a full instance running. All exception handling is left to the caller.
+   *
+   * @param propCacheKey
+   *          the prop cache key
+   * @param watcher
+   *          a prop store watcher that will receive / handle ZooKeeper events.
+   * @param zrw
+   *          a ZooReaderWriter
+   * @return the versioned properties or null if the node does not exist.
+   * @throws IOException
+   *           if the underlying data from the ZooKeeper node cannot be decoded.
+   * @throws KeeperException
+   *           if a ZooKeeper exception occurs
+   * @throws InterruptedException
+   *           if the ZooKeeper read was interrupted.
+   */
+  public static @Nullable VersionedProperties readFromZk(final PropCacheKey<?> propCacheKey,
+      final PropStoreWatcher watcher, final ZooReaderWriter zrw)
+      throws IOException, KeeperException, InterruptedException {
+    try {
+      Stat stat = new Stat();
+      byte[] bytes = zrw.getData(propCacheKey.getPath(), watcher, stat);
+      return codec.fromBytes(stat.getVersion(), bytes);
+    } catch (KeeperException.NoNodeException ex) {
+      // ignore no node - allow other exceptions to propagate
+      return null;
+    }
+  }
+
+  /**
+   * Copies all mappings from the specified map and into the existing property values and stores
+   * them into the backend store. New keys are added and keys the may have existed in the current
+   * properties are overwritten.
+   * <p>
+   * If multiple threads attempt to update values concurrently, this method will automatically
+   * retry. If the threads are setting different keys / values the result will be the sum of the
+   * changes. If the concurrent threads are attempting to set a value(s) for the same key(s), the
+   * value(s) will be the set to the values provided by the last thread to complete. The order is
+   * indeterminate.
+   *
+   * @param propCacheKey
+   *          the prop cache id
+   * @param props
+   *          a map of property k,v pairs
+   * @throws IllegalStateException
+   *           if the values cannot be written or if an underlying store exception occurs.
+   */
+  @Override
+  public void putAll(@NonNull PropCacheKey<?> propCacheKey, @NonNull Map<String,String> props) {
+    if (props.isEmpty()) {
+      return; // no props - noop
+    }
+    mutateVersionedProps(propCacheKey, VersionedProperties::addOrUpdate, props);
+  }
+
+  @Override
+  public void removeProperties(@NonNull PropCacheKey<?> propCacheKey,
+      @NonNull Collection<String> keys) {
+    if (keys.isEmpty()) {
+      return; // no keys - noop.
+    }
+    mutateVersionedProps(propCacheKey, VersionedProperties::remove, keys);
+  }
+
+  @Override
+  public void delete(@NonNull PropCacheKey<?> propCacheKey) {
+    Objects.requireNonNull(propCacheKey, "prop store delete() - Must provide propCacheId");
+    try {
+      log.trace("called delete() for: {}", propCacheKey);
+      final String path = propCacheKey.getPath();
+      zrw.delete(path);
+      cache.remove(propCacheKey);
+    } catch (KeeperException | InterruptedException ex) {
+      throw new IllegalStateException("Failed to delete properties for propCacheId " + propCacheKey,
+          ex);
+    }
+  }
+
+  private <T> void mutateVersionedProps(PropCacheKey<?> propCacheKey,
+      BiFunction<VersionedProperties,T,VersionedProperties> action, T changes) {
+
+    log.trace("mutateVersionedProps called for: {}", propCacheKey);
+
+    try {
+
+      VersionedProperties vProps = cache.getWithoutCaching(propCacheKey);
+      if (vProps == null) {
+        vProps = readPropsFromZk(propCacheKey);
+      }
+
+      for (int attempts = 3; attempts > 0; --attempts) {
+
+        VersionedProperties updates = action.apply(vProps, changes);
+
+        if (zrw.overwritePersistentData(propCacheKey.getPath(), codec.toBytes(updates),
+            updates.getDataVersion())) {
+          return;
+        }
+        Thread.sleep(20); // small pause to get thread to yield.
+        // re-read from zookeeper to ensure the latest version.
+        vProps = readPropsFromZk(propCacheKey);
+      }
+      throw new IllegalStateException(
+          "failed to remove properties to zooKeeper for " + propCacheKey, null);
+    } catch (IllegalArgumentException | IOException ex) {
+      throw new IllegalStateException(
+          "Codec failed to decode / encode properties for " + propCacheKey, ex);
+    } catch (InterruptedException | KeeperException ex) {
+      if (ex instanceof InterruptedException) {
+        Thread.currentThread().interrupt();
+      }
+      throw new IllegalStateException(
+          "failed to remove properties to zooKeeper for " + propCacheKey, ex);
+    }
+  }
+
+  @Override
+  public void registerAsListener(PropCacheKey<?> propCacheKey, PropChangeListener listener) {
+    propStoreWatcher.registerListener(propCacheKey, listener);
+  }
+
+  private void checkZkConnection() {
+    if (zkReadyMon.test()) {
+      return;
+    }
+
+    cache.removeAll();
+
+    // not ready block or propagate error if it times out.
+    zkReadyMon.isReady();
+
+  }
+
+  @Override
+  public void zkChangeEvent(PropCacheKey<?> propCacheKey) {
+    log.trace("Received change event from ZooKeeper for: {} removed from cache", propCacheKey);
+    cache.remove(propCacheKey);
+  }
+
+  /**
+   * NOOP for the prop store - the cache value will reflect the updated value on next read. The
+   * change is also sent to external listeners of the need to take action, but for the prop store,
+   * no additional action is required.
+   *
+   * @param propCacheKey
+   *          the prop cache id.
+   */
+  @Override
+  public void cacheChangeEvent(PropCacheKey<?> propCacheKey) {
+    log.trace("zkChangeEvent: {}", propCacheKey);
+  }
+
+  @Override
+  public void deleteEvent(PropCacheKey<?> propCacheKey) {
+    log.trace("deleteEvent: {}", propCacheKey);
+    cache.remove(propCacheKey);
+  }
+
+  @Override
+  public void connectionEvent() {
+    log.trace("connectionEvent");
+    cache.removeAll();
+  }
+
+  /** for testing - force Caffeine housekeeping to run. */
+  @VisibleForTesting
+  public void cleanUp() {
+    ((PropCacheCaffeineImpl) cache).cleanUp();
+  }
+
+  /**
+   * Read and decode property node from ZooKeeper.
+   *
+   * @param propCacheKey
+   *          the propCacheKey
+   * @return the decoded properties.
+   * @throws KeeperException
+   *           if a ZooKeeper exception occurs to allow caller to decide on action.
+   * @throws IOException
+   *           if the node data cannot be decoded into properties to allow the caller to decide on
+   *           action.
+   * @throws IllegalStateException
+   *           if an interrupt occurs. The interrupt status is reasserted and usually best to not
+   *           otherwise try to handle the exception.
+   */
+  private VersionedProperties readPropsFromZk(PropCacheKey<?> propCacheKey)
+      throws KeeperException, IOException {
+    try {
+      Stat stat = new Stat();
+      byte[] bytes = zrw.getData(propCacheKey.getPath(), stat);
+      return codec.fromBytes(stat.getVersion(), bytes);
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("Interrupt received during ZooKeeper read", ex);
+    }
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/util/ConfigPropertyUpgrader.java b/server/base/src/main/java/org/apache/accumulo/server/conf/util/ConfigPropertyUpgrader.java
new file mode 100644
index 0000000000..1888bca17c
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/util/ConfigPropertyUpgrader.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.util;
+
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.accumulo.core.Constants;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.cli.ServerUtilOpts;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.PropStoreWatcher;
+import org.apache.accumulo.server.conf.store.impl.ReadyMonitor;
+import org.apache.accumulo.start.spi.KeywordExecutable;
+import org.apache.zookeeper.KeeperException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.auto.service.AutoService;
+
+@AutoService(KeywordExecutable.class)
+public class ConfigPropertyUpgrader implements KeywordExecutable {
+
+  private static final Logger log = LoggerFactory.getLogger(ConfigPropertyUpgrader.class);
+
+  private final static VersionedPropCodec codec = VersionedPropCodec.getDefault();
+
+  public ConfigPropertyUpgrader() {}
+
+  public static void main(String[] args) throws Exception {
+    new ConfigPropertyUpgrader().execute(args);
+  }
+
+  @Override
+  public String keyword() {
+    return "config-upgrade";
+  }
+
+  @Override
+  public String description() {
+    return "converts properties store in ZooKeeper to 2.1 format";
+  }
+
+  @Override
+  public void execute(final String[] args) throws Exception {
+    ConfigPropertyUpgrader.Opts opts = new ConfigPropertyUpgrader.Opts();
+    opts.parseArgs(ConfigPropertyUpgrader.class.getName(), args);
+
+    ServerContext context = opts.getServerContext();
+
+    doUpgrade(context.getInstanceID(), context.getZooReaderWriter());
+  }
+
+  public void doUpgrade(final InstanceId instanceId, final ZooReaderWriter zrw) {
+
+    ReadyMonitor readyMonitor = new ReadyMonitor(ConfigPropertyUpgrader.class.getSimpleName(),
+        zrw.getSessionTimeout() * 2L);
+    PropStoreWatcher nullWatcher = new PropStoreWatcher(readyMonitor);
+
+    ConfigTransformer transformer = new ConfigTransformer(zrw, codec, nullWatcher);
+
+    upgradeSysProps(instanceId, transformer);
+    upgradeNamespaceProps(instanceId, zrw, transformer);
+    upgradeTableProps(instanceId, zrw, transformer);
+  }
+
+  private void upgradeSysProps(final InstanceId instanceId, final ConfigTransformer transformer) {
+    log.info("Upgrade system config properties for {}", instanceId);
+    transformer.transform(SystemPropKey.of(instanceId));
+  }
+
+  private void upgradeNamespaceProps(final InstanceId instanceId, final ZooReaderWriter zrw,
+      final ConfigTransformer transformer) {
+    String zkPathNamespaceBase = ZooUtil.getRoot(instanceId) + Constants.ZNAMESPACES;
+    try {
+      // sort is cosmetic - only improves readability and consistency in logs
+      Set<String> namespaces = new TreeSet<>(zrw.getChildren(zkPathNamespaceBase));
+      for (String namespace : namespaces) {
+        String zkPropBasePath = zkPathNamespaceBase + "/" + namespace + Constants.ZNAMESPACE_CONF;
+        log.info("Upgrading namespace {} base path: {}", namespace, zkPropBasePath);
+        transformer.transform(NamespacePropKey.of(instanceId, NamespaceId.of(namespace)));
+      }
+    } catch (KeeperException ex) {
+      throw new IllegalStateException(
+          "Failed to read namespaces from ZooKeeper for path: " + zkPathNamespaceBase, ex);
+    } catch (InterruptedException ex) {
+      throw new IllegalStateException(
+          "Interrupted reading namespaces from ZooKeeper for path: " + zkPathNamespaceBase, ex);
+    }
+  }
+
+  private void upgradeTableProps(final InstanceId instanceId, final ZooReaderWriter zrw,
+      ConfigTransformer transformer) {
+    String zkPathTableBase = ZooUtil.getRoot(instanceId) + Constants.ZTABLES;
+    try {
+      // sort is cosmetic - only improves readability and consistency in logs
+      Set<String> tables = new TreeSet<>(zrw.getChildren(zkPathTableBase));
+      for (String table : tables) {
+        String zkPropBasePath = zkPathTableBase + "/" + table + Constants.ZNAMESPACE_CONF;
+        log.info("Upgrading table {} base path: {}", table, zkPropBasePath);
+        transformer.transform(TablePropKey.of(instanceId, TableId.of(table)));
+      }
+    } catch (KeeperException ex) {
+      throw new IllegalStateException(
+          "Failed to read tables from ZooKeeper for path: " + zkPathTableBase, ex);
+    } catch (InterruptedException ex) {
+      throw new IllegalStateException(
+          "Interrupted reading tables from ZooKeeper for path: " + zkPathTableBase, ex);
+    }
+  }
+
+  static class Opts extends ServerUtilOpts {}
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/util/ConfigTransformer.java b/server/base/src/main/java/org/apache/accumulo/server/conf/util/ConfigTransformer.java
new file mode 100644
index 0000000000..ccaa15567b
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/util/ConfigTransformer.java
@@ -0,0 +1,395 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.util;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.accumulo.core.conf.DeprecatedPropertyUtil;
+import org.apache.accumulo.core.util.DurationFormat;
+import org.apache.accumulo.fate.util.Retry;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.impl.PropStoreWatcher;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Read legacy properties (pre 2.1) from ZooKeeper and transform them into the single node format.
+ * The encoded properties are stored in ZooKeeper and then the legacy property ZooKeeper nodes are
+ * deleted.
+ */
+public class ConfigTransformer {
+
+  private static final Logger log = LoggerFactory.getLogger(ConfigTransformer.class);
+
+  private final ZooReaderWriter zrw;
+  private final VersionedPropCodec codec;
+  private final PropStoreWatcher propStoreWatcher;
+  private final Retry retry;
+
+  /**
+   * Instantiate a transformer instance.
+   *
+   * @param zrw
+   *          a ZooReaderWriter
+   * @param codec
+   *          the codec used to encode to the single-node format.
+   * @param propStoreWatcher
+   *          the watcher registered to receive future notifications of changes to the encoded
+   *          property node.
+   */
+  public ConfigTransformer(final ZooReaderWriter zrw, VersionedPropCodec codec,
+      final PropStoreWatcher propStoreWatcher) {
+    this.zrw = zrw;
+    this.codec = codec;
+    this.propStoreWatcher = propStoreWatcher;
+
+    // default - allow for a conservative max delay of about a minute
+    retry =
+        Retry.builder().maxRetries(15).retryAfter(250, MILLISECONDS).incrementBy(500, MILLISECONDS)
+            .maxWait(5, SECONDS).backOffFactor(1.75).logInterval(3, MINUTES).createRetry();
+
+  }
+
+  public ConfigTransformer(final ZooReaderWriter zrw, VersionedPropCodec codec,
+      final PropStoreWatcher propStoreWatcher, final Retry retry) {
+    this.zrw = zrw;
+    this.codec = codec;
+    this.propStoreWatcher = propStoreWatcher;
+    this.retry = retry;
+  }
+
+  /**
+   * Transform the properties for the provided prop cache key.
+   *
+   * @return the encoded properties.
+   */
+  public VersionedProperties transform(final PropCacheKey<?> propCacheKey) {
+    TransformToken token = TransformToken.createToken(propCacheKey, zrw);
+    return transform(propCacheKey, token);
+  }
+
+  // Allow external (mocked) TransformToken to be used
+  @VisibleForTesting
+  VersionedProperties transform(final PropCacheKey<?> propCacheKey, final TransformToken token) {
+
+    log.info("checking for legacy property upgrade transform for {}", propCacheKey);
+
+    VersionedProperties results;
+    Instant start = Instant.now();
+    try {
+
+      // check for node - just return if it exists.
+      results = ZooPropStore.readFromZk(propCacheKey, propStoreWatcher, zrw);
+      if (results != null) {
+        log.debug(
+            "Found existing node at {}. skipping legacy prop conversion - version: {}, timestamp: {}",
+            propCacheKey, results.getDataVersion(), results.getTimestamp());
+        return results;
+      }
+
+      while (!token.haveTokenOwnership()) {
+        try {
+          retry.useRetry();
+          retry.waitForNextAttempt();
+          // look and return node if created while trying to token.
+          log.trace("own the token - look for existing encoded node at: {}",
+              propCacheKey.getPath());
+          results = ZooPropStore.readFromZk(propCacheKey, propStoreWatcher, zrw);
+          if (results != null) {
+            log.debug(
+                "Found existing node after getting token at {}. skipping legacy prop conversion - version: {}, timestamp: {}",
+                propCacheKey, results.getDataVersion(), results.getTimestamp());
+            return results;
+          }
+          // still does not exist - try again.
+          token.getTokenOwnership();
+        } catch (InterruptedException ex) {
+          Thread.currentThread().interrupt();
+          throw new IllegalStateException("Failed to hold transform token for " + propCacheKey, ex);
+        } catch (IllegalStateException ex) {
+          throw new IllegalStateException("Failed to hold transform token for " + propCacheKey, ex);
+        }
+      }
+
+      Set<LegacyPropNode> upgradeNodes = readLegacyProps(propCacheKey);
+      if (upgradeNodes == null) {
+        log.info("Found existing node after reading legacy props {}, skipping conversion",
+            propCacheKey);
+        results = ZooPropStore.readFromZk(propCacheKey, propStoreWatcher, zrw);
+        if (results != null) {
+          return results;
+        }
+      }
+
+      upgradeNodes = convertDeprecatedProps(propCacheKey, upgradeNodes);
+
+      results = writeConverted(propCacheKey, upgradeNodes);
+
+      if (results == null) {
+        throw new IllegalStateException("Could not create properties for " + propCacheKey);
+      }
+
+      // validate token still valid before deletion.
+      if (!token.validateToken()) {
+        throw new IllegalStateException(
+            "legacy conversion failed. Lost transform token for " + propCacheKey);
+      }
+
+      int errorCount = deleteLegacyProps(upgradeNodes);
+      log.debug("deleted legacy props - error count: {}", errorCount);
+      log.debug("property transform for {} took {} ms", propCacheKey,
+          new DurationFormat(Duration.between(start, Instant.now()).toMillis(), ""));
+
+      return results;
+
+    } catch (Exception ex) {
+      log.info("Exception on upgrading legacy properties for: " + propCacheKey, ex);
+    } finally {
+      token.releaseToken();
+    }
+    return null;
+  }
+
+  private Set<LegacyPropNode> convertDeprecatedProps(PropCacheKey<?> propCacheKey,
+      Set<LegacyPropNode> upgradeNodes) {
+
+    if (!(propCacheKey instanceof SystemPropKey)) {
+      return upgradeNodes;
+    }
+
+    Set<LegacyPropNode> renamedNodes = new TreeSet<>();
+
+    for (LegacyPropNode original : upgradeNodes) {
+      var finalName = DeprecatedPropertyUtil.getReplacementName(original.getPropName(),
+          (log, replacement) -> log
+              .info("Automatically renaming deprecated property '{}' with its replacement '{}'"
+                  + " in ZooKeeper configuration upgrade.", original, replacement));
+      LegacyPropNode renamed = new LegacyPropNode(original.getPath(), finalName, original.getData(),
+          original.getNodeVersion());
+      renamedNodes.add(renamed);
+    }
+    return renamedNodes;
+  }
+
+  private @Nullable Set<LegacyPropNode> readLegacyProps(PropCacheKey<?> propCacheKey) {
+
+    Set<LegacyPropNode> legacyProps = new TreeSet<>();
+
+    // strip leading slash
+    var tokenName = TransformToken.TRANSFORM_TOKEN.substring(1);
+
+    try {
+      var keyBasePath = propCacheKey.getBasePath();
+      List<String> childNames = zrw.getChildren(keyBasePath);
+      for (String propName : childNames) {
+        log.trace("processing ZooKeeper child node: {} for: {}", propName, propCacheKey);
+        if (tokenName.equals(propName)) {
+          continue;
+        }
+        if (PropCacheKey.PROP_NODE_NAME.equals(propName)) {
+          log.debug(
+              "encoded property node exists for {}. Legacy conversion ignoring conversion of this node",
+              propCacheKey);
+          return null;
+        }
+        log.trace("Adding: {} to list for legacy conversion", propName);
+
+        var path = keyBasePath + "/" + propName;
+        Stat stat = new Stat();
+        byte[] bytes = zrw.getData(path, stat);
+
+        try {
+          LegacyPropNode node;
+          if (stat.getDataLength() > 0) {
+            node = new LegacyPropNode(path, propName, new String(bytes, UTF_8), stat.getVersion());
+          } else {
+            node = new LegacyPropNode(path, propName, "", stat.getVersion());
+          }
+          legacyProps.add(node);
+        } catch (IllegalStateException ex) {
+          log.warn("Skipping invalid property at path " + path, ex);
+        }
+      }
+
+    } catch (KeeperException e) {
+      // TODO add handling
+      e.printStackTrace();
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      e.printStackTrace();
+    }
+    return legacyProps;
+  }
+
+  private int deleteLegacyProps(Set<LegacyPropNode> nodes) {
+    int errorCount = 0;
+    for (LegacyPropNode n : nodes) {
+      try {
+        zrw.deleteStrict(n.getPath(), n.getNodeVersion());
+      } catch (InterruptedException ex) {
+        Thread.currentThread().interrupt();
+        throw new IllegalStateException("interrupt received during upgrade node clean-up", ex);
+      } catch (KeeperException ex) {
+        errorCount++;
+        log.info("Failed to delete node during upgrade clean-up", ex);
+      }
+    }
+    return errorCount;
+  }
+
+  private @Nullable VersionedProperties writeConverted(final PropCacheKey<?> propCacheKey,
+      final Set<LegacyPropNode> nodes) {
+    final Map<String,String> props = new HashMap<>();
+    nodes.forEach(node -> props.put(node.getPropName(), node.getData()));
+    VersionedProperties vProps = new VersionedProperties(props);
+    String path = propCacheKey.getPath();
+    try {
+      try {
+        zrw.putPersistentData(path, codec.toBytes(vProps), ZooUtil.NodeExistsPolicy.FAIL);
+      } catch (KeeperException.NodeExistsException ex) {
+        vProps = ZooPropStore.readFromZk(propCacheKey, propStoreWatcher, zrw);
+      }
+    } catch (InterruptedException | IOException | KeeperException ex) {
+      if (ex instanceof InterruptedException) {
+        Thread.currentThread().interrupt();
+      }
+      throw new IllegalStateException(
+          "failed to create node for " + propCacheKey + " on conversion", ex);
+    }
+    if (!validateWrite(propCacheKey, vProps)) {
+      // failed validation
+      return null;
+    }
+
+    return vProps;
+  }
+
+  private boolean validateWrite(final PropCacheKey<?> propCacheKey,
+      final VersionedProperties vProps) {
+    try {
+      Stat stat = zrw.getStatus(propCacheKey.getPath(), propStoreWatcher);
+      if (stat == null) {
+        throw new IllegalStateException(
+            "failed to get stat to validate created node for " + propCacheKey);
+      }
+      return stat.getVersion() == vProps.getDataVersion();
+    } catch (KeeperException ex) {
+      throw new IllegalStateException("failed to validate created node for " + propCacheKey, ex);
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("failed to validate created node for " + propCacheKey, ex);
+    }
+  }
+
+  /**
+   * Immutable container for legacy ZooKeeper property node information.
+   */
+  private static class LegacyPropNode implements Comparable<LegacyPropNode> {
+    private final String path;
+    private final String propName;
+    private final String data;
+    private final int nodeVersion;
+
+    /**
+     * An immutable instance of legacy ZooKeeper property node information. It holds the property
+     * and the node stat for later comparison to enable detection of ZooKeeper node changes. If the
+     * legacy property name has been deprecated, the property is renamed and the conversion is noted
+     * in the log.
+     *
+     * @param path
+     *          the ZooKeeper path
+     * @param propName
+     *          the property name - if deprecated it will be stored as the updated name and the
+     *          conversion logged.
+     * @param data
+     *          the property value
+     * @param nodeVersion
+     *          the ZooKeeper stat data version.
+     */
+    public LegacyPropNode(@NonNull final String path, final String propName, final String data,
+        final int nodeVersion) {
+      this.path = path;
+      this.propName = propName;
+      this.data = data;
+      this.nodeVersion = nodeVersion;
+    }
+
+    public String getPath() {
+      return path;
+    }
+
+    public String getPropName() {
+      return propName;
+    }
+
+    public String getData() {
+      return data;
+    }
+
+    public int getNodeVersion() {
+      return nodeVersion;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o)
+        return true;
+      if (o == null || getClass() != o.getClass())
+        return false;
+      LegacyPropNode that = (LegacyPropNode) o;
+      return path.equals(that.path);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(path);
+    }
+
+    @Override
+    public int compareTo(LegacyPropNode other) {
+      return path.compareTo(other.path);
+    }
+  }
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/util/PropSnapshot.java b/server/base/src/main/java/org/apache/accumulo/server/conf/util/PropSnapshot.java
new file mode 100644
index 0000000000..28cc7371c0
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/util/PropSnapshot.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.util;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.PropChangeListener;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class provides a secondary, local cache of properties and is intended to be used to optimize
+ * constructing a configuration hierarchy from the underlying properties stored in ZooKeeper.
+ * <p>
+ * Configurations and especially the derivers use an updateCount to detect configuration changes.
+ * The updateCount is checked frequently and the configuration hierarchy rebuilt when a change is
+ * detected.
+ */
+public class PropSnapshot implements PropChangeListener {
+
+  private static final Logger log = LoggerFactory.getLogger(PropSnapshot.class);
+
+  private final Lock updateLock = new ReentrantLock();
+  private final AtomicBoolean needsUpdate = new AtomicBoolean(true);
+  private final AtomicReference<VersionedProperties> vPropRef = new AtomicReference<>();
+  private final PropCacheKey<?> propCacheKey;
+  private final PropStore propStore;
+
+  public static PropSnapshot create(final PropCacheKey<?> propCacheKey, final PropStore propStore) {
+    var ps = new PropSnapshot(propCacheKey, propStore);
+    propStore.registerAsListener(propCacheKey, ps);
+    return ps;
+  }
+
+  private PropSnapshot(final PropCacheKey<?> propCacheKey, final PropStore propStore) {
+    this.propCacheKey = propCacheKey;
+    this.propStore = propStore;
+  }
+
+  /**
+   * Get the current snapshot - updating if necessary.
+   *
+   * @return the current property snapshot.
+   */
+  public @NonNull VersionedProperties getVersionedProperties() {
+    updateSnapshot();
+    var answer = vPropRef.get();
+    if (answer == null) {
+      throw new IllegalStateException("Invalid state for property snapshot, no value has been set");
+    }
+    return answer;
+  }
+
+  /**
+   * Signal the current snapshot is invalid and needs to be updated on next access.
+   */
+  public void requireUpdate() {
+    updateLock.lock();
+    try {
+      needsUpdate.set(true);
+    } finally {
+      updateLock.unlock();
+    }
+  }
+
+  /**
+   * Update the current snapshot if a refresh is required.
+   *
+   * @throws IllegalStateException
+   *           if the properties cannot be retrieved from the underlying store.
+   */
+  private void updateSnapshot() {
+    if (!needsUpdate.get()) {
+      return;
+    }
+    updateLock.lock();
+    try {
+      // check after locked - another thread could have updated while waiting for lock
+      if (!needsUpdate.get()) {
+        return;
+      }
+      var vProps = propStore.get(propCacheKey);
+      vPropRef.set(vProps);
+      needsUpdate.set(false);
+    } finally {
+      updateLock.unlock();
+    }
+  }
+
+  @Override
+  public void zkChangeEvent(final PropCacheKey<?> eventPropKey) {
+    if (propCacheKey.equals(eventPropKey)) {
+      requireUpdate();
+    }
+  }
+
+  @Override
+  public void cacheChangeEvent(final PropCacheKey<?> eventPropKey) {
+    if (propCacheKey.equals(eventPropKey)) {
+      requireUpdate();
+    }
+  }
+
+  @Override
+  public void deleteEvent(final PropCacheKey<?> eventPropKey) {
+    if (propCacheKey.equals(eventPropKey)) {
+      requireUpdate();
+      log.debug("Received property delete event for {}", propCacheKey);
+    }
+  }
+
+  @Override
+  public void connectionEvent() {
+    requireUpdate();
+    log.debug("Received connection event - update properties required");
+  }
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/conf/util/TransformToken.java b/server/base/src/main/java/org/apache/accumulo/server/conf/util/TransformToken.java
new file mode 100644
index 0000000000..943fc8fa7a
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/conf/util/TransformToken.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.util;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.UUID;
+
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides a token used in property conversion. The token is used to limit the number of processes
+ * that try to create a property node and transform the legacy property format to the 2.1 encoded
+ * properties. Processes do not queue for a token (using a sequential node) - processes should look
+ * for the token to exist, and if present wait and then periodically re-check for the property node
+ * to be created by the process that created / has the token.
+ * <p>
+ * Features
+ * <ul>
+ * <li>Uses ephemeral node that will be removed if transform process terminates without
+ * completing</li>
+ * <li>Watcher not necessary - the existence of the node and uuid in data sufficient to detect
+ * changes.</li>
+ * </ul>
+ */
+public class TransformToken {
+  public static final String TRANSFORM_TOKEN = "/transform_token";
+  private static final Logger log = LoggerFactory.getLogger(TransformToken.class);
+  private final TokenUUID tokenUUID = new TokenUUID();
+  private final String path;
+  private final ZooReaderWriter zrw;
+  private boolean haveToken = false;
+
+  private TransformToken(final @NonNull PropCacheKey<?> key, final ZooReaderWriter zrw) {
+    path = key.getBasePath() + TRANSFORM_TOKEN;
+    this.zrw = zrw;
+
+    boolean t = getTokenOwnership();
+    log.trace("created token - token held: {}", t);
+  }
+
+  /**
+   * Create a lock node in ZooKeeper using an ephemeral node. Will not throw and exception except on
+   * an interrupt. If the lock node is created, the returned lock will be locked. If another lock
+   * already exists, the lock is unlocked and the caller can decide to either wait for the resource
+   * to be created by the thread that created the lock, or try calling to {@code lock} to succeed
+   *
+   * @param key
+   *          a PropCacheKey that defines the storage location of the created lock and the
+   *          associated property nodes.
+   * @param zrw
+   *          a ZooReaderWriter
+   * @return an TransformLock instance.
+   * @throws IllegalStateException
+   *           is the lock creation fails due to an underlying ZooKeeper exception.
+   */
+  public static TransformToken createToken(final @NonNull PropCacheKey<?> key,
+      final ZooReaderWriter zrw) {
+    return new TransformToken(key, zrw);
+  }
+
+  /**
+   * Create and try to establish ownership (hold the token). Token ownership can be tested with
+   * {@link #haveTokenOwnership() haveTokenOwnership}
+   *
+   * @return true if able to get ownership, false otherwise.
+   */
+  public boolean getTokenOwnership() {
+    if (haveToken) {
+      return true;
+    }
+    try {
+      // existence check should be lighter-weight than failing on NODE_EXISTS exception
+      if (zrw.exists(path)) {
+        return false;
+      }
+      // if this completes this thread has created the lock
+      zrw.putEphemeralData(path, tokenUUID.asBytes());
+      log.trace("wrote property transform token: {} - {}", path, tokenUUID);
+      haveToken = true;
+      return true;
+    } catch (KeeperException ex) {
+      log.debug(
+          "Failed to write transform token for " + path + " another process may have created one",
+          ex);
+      return false;
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("Interrupted getting transform token", ex);
+    }
+  }
+
+  /**
+   * Return the token ownership status
+   *
+   * @return true if this instance has ownership of the token, false otherwise.
+   */
+  public boolean haveTokenOwnership() {
+    return haveToken;
+  }
+
+  /**
+   * Verify ownership is still valid while holding the token.
+   *
+   * @return true if token is still owned, false otherwise
+   */
+  public boolean validateToken() {
+    try {
+      byte[] readId = zrw.getData(path);
+      log.trace("validate token: read: {} - expected: {}", readId, tokenUUID);
+      return Arrays.equals(readId, tokenUUID.asBytes());
+    } catch (KeeperException ex) {
+      throw new IllegalStateException("Failed to validate token", ex);
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("Interrupted while validating token", ex);
+    }
+  }
+
+  /**
+   * If the token was created by this instance, the uuid of this instance and the uuid stored in the
+   * ZooKeeper data will match.
+   */
+  public void releaseToken() {
+    try {
+      if (log.isTraceEnabled()) {
+        log.trace("releaseToken called - {} - exists in ZooKeeper: {}", path, zrw.exists(path));
+      }
+
+      Stat stat = new Stat();
+      byte[] readId = zrw.getData(path, stat);
+      if (!Arrays.equals(readId, tokenUUID.asBytes())) {
+        throw new IllegalStateException(
+            "tried to release a token that was not held by current thread");
+      }
+
+      if (log.isTraceEnabled()) {
+        log.trace("releaseToken read id: {} - exists: {}", readId, zrw.exists(path));
+      }
+
+      // make sure we are deleting the same node version just checked.
+      zrw.deleteStrict(path, stat.getVersion());
+    } catch (KeeperException ex) {
+      throw new IllegalStateException("Failed to release transform lock for " + path, ex);
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("Interrupted getting transform token", ex);
+    }
+    haveToken = false;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o)
+      return true;
+    if (o == null || getClass() != o.getClass())
+      return false;
+    TransformToken that = (TransformToken) o;
+    return path.equals(that.path) && Arrays.equals(tokenUUID.asBytes(), that.tokenUUID.asBytes());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(path, Arrays.hashCode(tokenUUID.asBytes()));
+  }
+
+  @Override
+  public String toString() {
+    return "TransformLock{ path='" + path + "', locked='" + haveToken + "' id=" + tokenUUID + "'}'";
+  }
+
+  private static class TokenUUID {
+    private final String id = UUID.randomUUID().toString();
+    private final byte[] idBytes = id.getBytes(UTF_8);
+
+    public byte[] asBytes() {
+      return idBytes;
+    }
+
+    @Override
+    public String toString() {
+      return "TransformToken{id='" + id + '}';
+    }
+  }
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/init/FileSystemInitializer.java b/server/base/src/main/java/org/apache/accumulo/server/init/FileSystemInitializer.java
index 5ebc189ac4..3c020415b2 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/init/FileSystemInitializer.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/init/FileSystemInitializer.java
@@ -51,9 +51,9 @@ import org.apache.accumulo.core.spi.fs.VolumeChooserEnvironment;
 import org.apache.accumulo.core.util.ColumnFQ;
 import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
 import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.store.TablePropKey;
 import org.apache.accumulo.server.fs.VolumeChooserEnvironmentImpl;
 import org.apache.accumulo.server.fs.VolumeManager;
-import org.apache.accumulo.server.util.TablePropUtil;
 import org.apache.hadoop.fs.FileStatus;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
@@ -68,13 +68,9 @@ class FileSystemInitializer {
 
   // config only for root table
   private final InitialConfiguration initConfig;
-  private final ZooReaderWriter zoo;
-  private final String zkRoot;
 
   FileSystemInitializer(InitialConfiguration initConfig, ZooReaderWriter zoo, InstanceId uuid) {
     this.initConfig = initConfig;
-    this.zoo = zoo;
-    this.zkRoot = Constants.ZROOT + "/" + uuid;
   }
 
   private static class Tablet {
@@ -96,7 +92,7 @@ class FileSystemInitializer {
       ServerContext context) throws IOException, InterruptedException, KeeperException {
     SiteConfiguration siteConfig = initConfig.getSiteConf();
     // initialize initial system tables config in zookeeper
-    initSystemTablesConfig();
+    initSystemTablesConfig(context);
 
     Text splitPoint = MetadataSchema.TabletsSection.getRange().getEndKey().getRow();
 
@@ -160,20 +156,23 @@ class FileSystemInitializer {
     }
   }
 
-  private void initSystemTablesConfig() throws IOException, InterruptedException, KeeperException {
-    setTableProperties(RootTable.ID, initConfig.getRootTableConf());
-    setTableProperties(RootTable.ID, initConfig.getRootMetaConf());
-    setTableProperties(MetadataTable.ID, initConfig.getRootMetaConf());
-    setTableProperties(MetadataTable.ID, initConfig.getMetaTableConf());
-    setTableProperties(REPL_TABLE_ID, initConfig.getReplTableConf());
+  private void initSystemTablesConfig(final ServerContext context)
+      throws IOException, InterruptedException, KeeperException {
+    setTableProperties(context, RootTable.ID, initConfig.getRootTableConf());
+    setTableProperties(context, RootTable.ID, initConfig.getRootMetaConf());
+    setTableProperties(context, MetadataTable.ID, initConfig.getRootMetaConf());
+    setTableProperties(context, MetadataTable.ID, initConfig.getMetaTableConf());
+    setTableProperties(context, REPL_TABLE_ID, initConfig.getReplTableConf());
   }
 
-  private void setTableProperties(TableId tableId, HashMap<String,String> props)
-      throws IOException, InterruptedException, KeeperException {
-    for (Map.Entry<String,String> entry : props.entrySet()) {
-      if (!TablePropUtil.setTableProperty(zoo, zkRoot, tableId, entry.getKey(), entry.getValue())) {
-        throw new IOException("Cannot create per-table property " + entry.getKey());
-      }
+  private void setTableProperties(final ServerContext context, TableId tableId,
+      HashMap<String,String> props) {
+    var propStore = context.getPropStore();
+    TablePropKey tablePropKey = TablePropKey.of(context, tableId);
+    if (propStore.exists(tablePropKey)) {
+      propStore.putAll(tablePropKey, props);
+    } else {
+      propStore.create(tablePropKey, props);
     }
   }
 
diff --git a/server/base/src/main/java/org/apache/accumulo/server/init/Initialize.java b/server/base/src/main/java/org/apache/accumulo/server/init/Initialize.java
index 9a5cdbbc43..0692a5fe71 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/init/Initialize.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/init/Initialize.java
@@ -158,9 +158,12 @@ public class Initialize implements KeywordExecutable {
       return false;
     }
 
-    InstanceId iid = InstanceId.of(UUID.randomUUID());
+    InstanceId instanceId = InstanceId.of(UUID.randomUUID());
+    ZooKeeperInitializer zki = new ZooKeeperInitializer();
+    zki.initializeConfig(instanceId, zoo);
+
     try (ServerContext context =
-        ServerContext.initialize(initConfig.getSiteConf(), instanceName, iid)) {
+        ServerContext.initialize(initConfig.getSiteConf(), instanceName, instanceId)) {
       var chooserEnv = new VolumeChooserEnvironmentImpl(Scope.INIT, RootTable.ID, null, context);
       String rootTabletDirName = RootTable.ROOT_TABLET_DIR_NAME;
       String ext = FileOperations.getNewFileExtension(DefaultConfiguration.getInstance());
@@ -168,14 +171,13 @@ public class Initialize implements KeywordExecutable {
           fs.choose(chooserEnv, initConfig.getVolumeUris()) + SEPARATOR + TABLE_DIR + SEPARATOR
               + RootTable.ID + SEPARATOR + rootTabletDirName + SEPARATOR + "00000_00000." + ext)
                   .toString();
-
-      ZooKeeperInitializer zki = new ZooKeeperInitializer();
-      zki.initialize(zoo, opts.clearInstanceName, iid, instanceNamePath, rootTabletDirName,
+      zki.initialize(context, opts.clearInstanceName, instanceNamePath, rootTabletDirName,
           rootTabletFileUri);
-      if (!createDirs(fs, iid, initConfig.getVolumeUris())) {
+
+      if (!createDirs(fs, instanceId, initConfig.getVolumeUris())) {
         throw new IOException("Problem creating directories on " + fs.getVolumes());
       }
-      var fileSystemInitializer = new FileSystemInitializer(initConfig, zoo, iid);
+      var fileSystemInitializer = new FileSystemInitializer(initConfig, zoo, instanceId);
       var rootVol = fs.choose(chooserEnv, initConfig.getVolumeUris());
       var rootPath = new Path(rootVol + SEPARATOR + TABLE_DIR + SEPARATOR + RootTable.ID + SEPARATOR
           + rootTabletDirName);
@@ -249,7 +251,7 @@ public class Initialize implements KeywordExecutable {
     }
   }
 
-  private static boolean createDirs(VolumeManager fs, InstanceId iid, Set<String> baseDirs) {
+  private static boolean createDirs(VolumeManager fs, InstanceId instanceId, Set<String> baseDirs) {
     try {
       for (String baseDir : baseDirs) {
         fs.mkdirs(
@@ -257,7 +259,7 @@ public class Initialize implements KeywordExecutable {
             new FsPermission("700"));
         Path iidLocation = new Path(baseDir, Constants.INSTANCE_ID_DIR);
         fs.mkdirs(iidLocation);
-        fs.createNewFile(new Path(iidLocation, iid.canonical()));
+        fs.createNewFile(new Path(iidLocation, instanceId.canonical()));
         log.info("Created directory {}", baseDir);
       }
       return true;
@@ -416,7 +418,7 @@ public class Initialize implements KeywordExecutable {
     Path iidPath = new Path(aBasePath, Constants.INSTANCE_ID_DIR);
     Path versionPath = new Path(aBasePath, Constants.VERSION_DIR);
 
-    InstanceId iid = VolumeManager.getInstanceIDFromHdfs(iidPath, hadoopConf);
+    InstanceId instanceId = VolumeManager.getInstanceIDFromHdfs(iidPath, hadoopConf);
     for (Pair<Path,Path> replacementVolume : serverDirs.getVolumeReplacements()) {
       if (aBasePath.equals(replacementVolume.getFirst())) {
         log.error(
@@ -438,7 +440,7 @@ public class Initialize implements KeywordExecutable {
       log.error("Problem getting accumulo data version", e);
       return false;
     }
-    return createDirs(fs, iid, uinitializedDirs);
+    return createDirs(fs, instanceId, uinitializedDirs);
   }
 
   private static class Opts extends Help {
diff --git a/server/base/src/main/java/org/apache/accumulo/server/init/ZooKeeperInitializer.java b/server/base/src/main/java/org/apache/accumulo/server/init/ZooKeeperInitializer.java
index 822f4c4376..5d377cf00a 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/init/ZooKeeperInitializer.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/init/ZooKeeperInitializer.java
@@ -21,6 +21,8 @@ package org.apache.accumulo.server.init;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.accumulo.server.init.Initialize.REPL_TABLE_ID;
 
+import java.io.IOException;
+
 import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.clientImpl.Namespace;
 import org.apache.accumulo.core.data.InstanceId;
@@ -31,6 +33,10 @@ import org.apache.accumulo.core.metadata.RootTable;
 import org.apache.accumulo.core.metadata.schema.RootTabletMetadata;
 import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
 import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
 import org.apache.accumulo.server.log.WalStateManager;
 import org.apache.accumulo.server.metadata.RootGcCandidates;
 import org.apache.accumulo.server.tables.TableManager;
@@ -39,12 +45,56 @@ import org.apache.zookeeper.ZooDefs;
 
 class ZooKeeperInitializer {
 
-  void initialize(ZooReaderWriter zoo, boolean clearInstanceName, InstanceId iid,
-      String instanceNamePath, String rootTabletDirName, String rootTabletFileUri)
+  private final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+  private final byte[] ZERO_CHAR_ARRAY = {'0'};
+
+  /**
+   * The prop store requires that the system config node exists so that it can set watchers. This
+   * methods creates the ZooKeeper nodes /accumulo/INSTANCE_ID/config/encoded_props and sets the
+   * encoded_props to a default, empty node if it does not exist. If any of the paths or the
+   * encoded_props node exist, the are skipped and not modified.
+   *
+   * @param instanceId
+   *          the instance id
+   * @param zoo
+   *          a ZooReaderWriter
+   */
+  void initializeConfig(final InstanceId instanceId, final ZooReaderWriter zoo) {
+    try {
+
+      zoo.putPersistentData(Constants.ZROOT, new byte[0], ZooUtil.NodeExistsPolicy.SKIP,
+          ZooDefs.Ids.OPEN_ACL_UNSAFE);
+
+      String zkInstanceRoot = Constants.ZROOT + "/" + instanceId;
+      zoo.putPersistentData(zkInstanceRoot, EMPTY_BYTE_ARRAY, ZooUtil.NodeExistsPolicy.SKIP);
+      zoo.putPersistentData(zkInstanceRoot + Constants.ZCONFIG, EMPTY_BYTE_ARRAY,
+          ZooUtil.NodeExistsPolicy.SKIP);
+
+      var sysPropPath = SystemPropKey.of(instanceId).getPath();
+      VersionedProperties vProps = new VersionedProperties();
+      // skip if the encoded props node exists
+      if (zoo.exists(sysPropPath)) {
+        return;
+      }
+      var created = zoo.putPersistentData(sysPropPath,
+          VersionedPropCodec.getDefault().toBytes(vProps), ZooUtil.NodeExistsPolicy.FAIL);
+      if (!created) {
+        throw new IllegalStateException(
+            "Failed to create default system props during initialization at: {}" + sysPropPath);
+      }
+    } catch (IOException | KeeperException | InterruptedException ex) {
+      throw new IllegalStateException("Failed to initialize configuration for prop store", ex);
+    }
+  }
+
+  void initialize(final ServerContext context, final boolean clearInstanceName,
+      final String instanceNamePath, final String rootTabletDirName, final String rootTabletFileUri)
       throws KeeperException, InterruptedException {
     // setup basic data in zookeeper
-    zoo.putPersistentData(Constants.ZROOT, new byte[0], ZooUtil.NodeExistsPolicy.SKIP,
-        ZooDefs.Ids.OPEN_ACL_UNSAFE);
+
+    ZooReaderWriter zoo = context.getZooReaderWriter();
+    InstanceId instanceId = context.getInstanceID();
+
     zoo.putPersistentData(Constants.ZROOT + Constants.ZINSTANCES, new byte[0],
         ZooUtil.NodeExistsPolicy.SKIP, ZooDefs.Ids.OPEN_ACL_UNSAFE);
 
@@ -52,30 +102,28 @@ class ZooKeeperInitializer {
     if (clearInstanceName) {
       zoo.recursiveDelete(instanceNamePath, ZooUtil.NodeMissingPolicy.SKIP);
     }
-    zoo.putPersistentData(instanceNamePath, iid.canonical().getBytes(UTF_8),
+    zoo.putPersistentData(instanceNamePath, instanceId.canonical().getBytes(UTF_8),
         ZooUtil.NodeExistsPolicy.FAIL);
 
-    final byte[] EMPTY_BYTE_ARRAY = new byte[0];
-    final byte[] ZERO_CHAR_ARRAY = {'0'};
-
     // setup the instance
-    String zkInstanceRoot = Constants.ZROOT + "/" + iid;
-    zoo.putPersistentData(zkInstanceRoot, EMPTY_BYTE_ARRAY, ZooUtil.NodeExistsPolicy.FAIL);
+    String zkInstanceRoot = Constants.ZROOT + "/" + instanceId;
     zoo.putPersistentData(zkInstanceRoot + Constants.ZTABLES, Constants.ZTABLES_INITIAL_ID,
         ZooUtil.NodeExistsPolicy.FAIL);
     zoo.putPersistentData(zkInstanceRoot + Constants.ZNAMESPACES, new byte[0],
         ZooUtil.NodeExistsPolicy.FAIL);
-    TableManager.prepareNewNamespaceState(zoo, iid, Namespace.DEFAULT.id(),
-        Namespace.DEFAULT.name(), ZooUtil.NodeExistsPolicy.FAIL);
-    TableManager.prepareNewNamespaceState(zoo, iid, Namespace.ACCUMULO.id(),
+
+    TableManager.prepareNewNamespaceState(context, Namespace.DEFAULT.id(), Namespace.DEFAULT.name(),
+        ZooUtil.NodeExistsPolicy.FAIL);
+    TableManager.prepareNewNamespaceState(context, Namespace.ACCUMULO.id(),
         Namespace.ACCUMULO.name(), ZooUtil.NodeExistsPolicy.FAIL);
-    TableManager.prepareNewTableState(zoo, iid, RootTable.ID, Namespace.ACCUMULO.id(),
+
+    TableManager.prepareNewTableState(context, RootTable.ID, Namespace.ACCUMULO.id(),
         RootTable.NAME, TableState.ONLINE, ZooUtil.NodeExistsPolicy.FAIL);
-    TableManager.prepareNewTableState(zoo, iid, MetadataTable.ID, Namespace.ACCUMULO.id(),
+    TableManager.prepareNewTableState(context, MetadataTable.ID, Namespace.ACCUMULO.id(),
         MetadataTable.NAME, TableState.ONLINE, ZooUtil.NodeExistsPolicy.FAIL);
     @SuppressWarnings("deprecation")
     String replicationTableName = org.apache.accumulo.core.replication.ReplicationTable.NAME;
-    TableManager.prepareNewTableState(zoo, iid, REPL_TABLE_ID, Namespace.ACCUMULO.id(),
+    TableManager.prepareNewTableState(context, REPL_TABLE_ID, Namespace.ACCUMULO.id(),
         replicationTableName, TableState.OFFLINE, ZooUtil.NodeExistsPolicy.FAIL);
     zoo.putPersistentData(zkInstanceRoot + Constants.ZTSERVERS, EMPTY_BYTE_ARRAY,
         ZooUtil.NodeExistsPolicy.FAIL);
@@ -96,8 +144,6 @@ class ZooKeeperInitializer {
         ZooUtil.NodeExistsPolicy.FAIL);
     zoo.putPersistentData(zkInstanceRoot + Constants.ZGC_LOCK, EMPTY_BYTE_ARRAY,
         ZooUtil.NodeExistsPolicy.FAIL);
-    zoo.putPersistentData(zkInstanceRoot + Constants.ZCONFIG, EMPTY_BYTE_ARRAY,
-        ZooUtil.NodeExistsPolicy.FAIL);
     zoo.putPersistentData(zkInstanceRoot + Constants.ZTABLE_LOCKS, EMPTY_BYTE_ARRAY,
         ZooUtil.NodeExistsPolicy.FAIL);
     zoo.putPersistentData(zkInstanceRoot + Constants.ZHDFS_RESERVATIONS, EMPTY_BYTE_ARRAY,
diff --git a/server/base/src/main/java/org/apache/accumulo/server/tables/TableManager.java b/server/base/src/main/java/org/apache/accumulo/server/tables/TableManager.java
index 25f95ac3fb..1b532e7cae 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/tables/TableManager.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/tables/TableManager.java
@@ -24,7 +24,6 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 
 import org.apache.accumulo.core.Constants;
@@ -40,7 +39,9 @@ import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
 import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeExistsPolicy;
 import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeMissingPolicy;
 import org.apache.accumulo.server.ServerContext;
-import org.apache.accumulo.server.util.TablePropUtil;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.TablePropKey;
 import org.apache.zookeeper.KeeperException;
 import org.apache.zookeeper.WatchedEvent;
 import org.apache.zookeeper.Watcher;
@@ -62,11 +63,11 @@ public class TableManager {
   private final String zkRoot;
   private final InstanceId instanceID;
   private final ZooReaderWriter zoo;
-  private ZooCache zooStateCache;
+  private final ZooCache zooStateCache;
 
-  public static void prepareNewNamespaceState(ZooReaderWriter zoo, InstanceId instanceId,
-      NamespaceId namespaceId, String namespace, NodeExistsPolicy existsPolicy)
-      throws KeeperException, InterruptedException {
+  public static void prepareNewNamespaceState(ZooReaderWriter zoo, final PropStore propStore,
+      InstanceId instanceId, NamespaceId namespaceId, String namespace,
+      NodeExistsPolicy existsPolicy) throws KeeperException, InterruptedException {
     log.debug("Creating ZooKeeper entries for new namespace {} (ID: {})", namespace, namespaceId);
     String zPath = Constants.ZROOT + "/" + instanceId + Constants.ZNAMESPACES + "/" + namespaceId;
 
@@ -74,11 +75,24 @@ public class TableManager {
     zoo.putPersistentData(zPath + Constants.ZNAMESPACE_NAME, namespace.getBytes(UTF_8),
         existsPolicy);
     zoo.putPersistentData(zPath + Constants.ZNAMESPACE_CONF, new byte[0], existsPolicy);
+
+    var propKey = NamespacePropKey.of(instanceId, namespaceId);
+    if (!propStore.exists(propKey)) {
+      propStore.create(propKey, Map.of());
+    }
   }
 
-  public static void prepareNewTableState(ZooReaderWriter zoo, InstanceId instanceId,
-      TableId tableId, NamespaceId namespaceId, String tableName, TableState state,
-      NodeExistsPolicy existsPolicy) throws KeeperException, InterruptedException {
+  public static void prepareNewNamespaceState(final ServerContext context, NamespaceId namespaceId,
+      String namespace, NodeExistsPolicy existsPolicy)
+      throws KeeperException, InterruptedException {
+    prepareNewNamespaceState(context.getZooReaderWriter(), context.getPropStore(),
+        context.getInstanceID(), namespaceId, namespace, existsPolicy);
+  }
+
+  public static void prepareNewTableState(ZooReaderWriter zoo, PropStore propStore,
+      InstanceId instanceId, TableId tableId, NamespaceId namespaceId, String tableName,
+      TableState state, NodeExistsPolicy existsPolicy)
+      throws KeeperException, InterruptedException {
     // state gets created last
     log.debug("Creating ZooKeeper entries for new table {} (ID: {}) in namespace (ID: {})",
         tableName, tableId, namespaceId);
@@ -96,6 +110,17 @@ public class TableManager {
     zoo.putPersistentData(zTablePath + Constants.ZTABLE_COMPACT_CANCEL_ID, ZERO_BYTE, existsPolicy);
     zoo.putPersistentData(zTablePath + Constants.ZTABLE_STATE, state.name().getBytes(UTF_8),
         existsPolicy);
+    var propKey = TablePropKey.of(instanceId, tableId);
+    if (!propStore.exists(propKey)) {
+      propStore.create(propKey, Map.of());
+    }
+  }
+
+  public static void prepareNewTableState(final ServerContext context, TableId tableId,
+      NamespaceId namespaceId, String tableName, TableState state, NodeExistsPolicy existsPolicy)
+      throws KeeperException, InterruptedException {
+    prepareNewTableState(context.getZooReaderWriter(), context.getPropStore(),
+        context.getInstanceID(), tableId, namespaceId, tableName, state, existsPolicy);
   }
 
   public TableManager(ServerContext context) {
@@ -183,16 +208,16 @@ public class TableManager {
 
   public void addTable(TableId tableId, NamespaceId namespaceId, String tableName)
       throws KeeperException, InterruptedException, NamespaceNotFoundException {
-    prepareNewTableState(zoo, instanceID, tableId, namespaceId, tableName, TableState.NEW,
-        NodeExistsPolicy.OVERWRITE);
+    prepareNewTableState(zoo, context.getPropStore(), instanceID, tableId, namespaceId, tableName,
+        TableState.NEW, NodeExistsPolicy.OVERWRITE);
     updateTableStateCache(tableId);
   }
 
   public void cloneTable(TableId srcTableId, TableId tableId, String tableName,
       NamespaceId namespaceId, Map<String,String> propertiesToSet, Set<String> propertiesToExclude)
       throws KeeperException, InterruptedException {
-    prepareNewTableState(zoo, instanceID, tableId, namespaceId, tableName, TableState.NEW,
-        NodeExistsPolicy.OVERWRITE);
+    prepareNewTableState(zoo, context.getPropStore(), instanceID, tableId, namespaceId, tableName,
+        TableState.NEW, NodeExistsPolicy.OVERWRITE);
 
     String srcTablePath = Constants.ZROOT + "/" + instanceID + Constants.ZTABLES + "/" + srcTableId
         + Constants.ZTABLE_CONF;
@@ -200,12 +225,8 @@ public class TableManager {
         + Constants.ZTABLE_CONF;
     zoo.recursiveCopyPersistentOverwrite(srcTablePath, newTablePath);
 
-    for (Entry<String,String> entry : propertiesToSet.entrySet())
-      TablePropUtil.setTableProperty(context, tableId, entry.getKey(), entry.getValue());
-
-    for (String prop : propertiesToExclude)
-      zoo.recursiveDelete(Constants.ZROOT + "/" + instanceID + Constants.ZTABLES + "/" + tableId
-          + Constants.ZTABLE_CONF + "/" + prop, NodeMissingPolicy.SKIP);
+    context.tablePropUtil().setProperties(tableId, propertiesToSet);
+    context.tablePropUtil().removeProperties(tableId, propertiesToExclude);
 
     updateTableStateCache(tableId);
   }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/NamespacePropUtil.java b/server/base/src/main/java/org/apache/accumulo/server/util/NamespacePropUtil.java
index bb809c61c0..f141513126 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/NamespacePropUtil.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/NamespacePropUtil.java
@@ -18,45 +18,36 @@
  */
 package org.apache.accumulo.server.util;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import java.util.Collection;
+import java.util.Map;
 
-import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.data.NamespaceId;
-import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
-import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeExistsPolicy;
-import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeMissingPolicy;
 import org.apache.accumulo.server.ServerContext;
-import org.apache.zookeeper.KeeperException;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
 
-public class NamespacePropUtil {
-  public static boolean setNamespaceProperty(ServerContext context, NamespaceId namespaceId,
-      String property, String value) throws KeeperException, InterruptedException {
-    if (!Property.isTablePropertyValid(property, value))
-      return false;
+public class NamespacePropUtil extends PropUtil<NamespaceId> {
 
-    ZooReaderWriter zoo = context.getZooReaderWriter();
-
-    // create the zk node for per-namespace properties for this namespace if it doesn't already
-    // exist
-    String zkNamespacePath = getPath(context, namespaceId);
-    zoo.putPersistentData(zkNamespacePath, new byte[0], NodeExistsPolicy.SKIP);
-
-    // create the zk node for this property and set it's data to the specified value
-    String zPath = zkNamespacePath + "/" + property;
-    zoo.putPersistentData(zPath, value.getBytes(UTF_8), NodeExistsPolicy.OVERWRITE);
-
-    return true;
+  public NamespacePropUtil(ServerContext context) {
+    super(context);
   }
 
-  public static void removeNamespaceProperty(ServerContext context, NamespaceId namespaceId,
-      String property) throws InterruptedException, KeeperException {
-    String zPath = getPath(context, namespaceId) + "/" + property;
-    context.getZooReaderWriter().recursiveDelete(zPath, NodeMissingPolicy.SKIP);
+  @Override
+  public void setProperties(NamespaceId namespaceId, Map<String,String> properties) {
+    for (Map.Entry<String,String> prop : properties.entrySet()) {
+      // TODO reconcile with TablePropUtil see https://github.com/apache/accumulo/issues/2633
+      if (!Property.isTablePropertyValid(prop.getKey(), prop.getValue())) {
+        throw new IllegalArgumentException("Invalid table property for namespace: " + namespaceId
+            + " name: " + prop.getKey() + ", value: " + prop.getValue());
+      }
+    }
+    context.getPropStore().putAll(NamespacePropKey.of(context, namespaceId), properties);
   }
 
-  private static String getPath(ServerContext context, NamespaceId namespaceId) {
-    return context.getZooKeeperRoot() + Constants.ZNAMESPACES + "/" + namespaceId
-        + Constants.ZNAMESPACE_CONF;
+  @Override
+  public void removeProperties(NamespaceId namespaceId, Collection<String> propertyNames) {
+    context.getPropStore().removeProperties(NamespacePropKey.of(context, namespaceId),
+        propertyNames);
   }
+
 }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/PropUtil.java b/server/base/src/main/java/org/apache/accumulo/server/util/PropUtil.java
new file mode 100644
index 0000000000..5a30dc5527
--- /dev/null
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/PropUtil.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.util;
+
+import java.util.Collection;
+import java.util.Map;
+
+import org.apache.accumulo.core.data.AbstractId;
+import org.apache.accumulo.server.ServerContext;
+
+public abstract class PropUtil<T extends AbstractId<T>> {
+
+  protected final ServerContext context;
+
+  protected PropUtil(ServerContext context) {
+    this.context = context;
+  }
+
+  public abstract void setProperties(final T id, final Map<String,String> props);
+
+  public abstract void removeProperties(final T id, final Collection<String> propertyNames);
+
+}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/SystemPropUtil.java b/server/base/src/main/java/org/apache/accumulo/server/util/SystemPropUtil.java
index 35ba55dcb0..b3208d5519 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/SystemPropUtil.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/SystemPropUtil.java
@@ -18,16 +18,14 @@
  */
 package org.apache.accumulo.server.util;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import java.util.List;
+import java.util.Map;
 
-import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.conf.DeprecatedPropertyUtil;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.conf.PropertyType;
-import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeExistsPolicy;
-import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeMissingPolicy;
 import org.apache.accumulo.server.ServerContext;
-import org.apache.zookeeper.KeeperException;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -35,15 +33,13 @@ public class SystemPropUtil {
 
   private static final Logger log = LoggerFactory.getLogger(SystemPropUtil.class);
 
-  public static void setSystemProperty(ServerContext context, String property, String value)
-      throws KeeperException, InterruptedException {
+  public static void setSystemProperty(ServerContext context, String property, String value) {
     // Retrieve the replacement name for this property, if there is one.
     // Do this before we check if the name is a valid zookeeper name.
     final var original = property;
-    property = DeprecatedPropertyUtil.getReplacementName(property, (log, replacement) -> {
-      log.warn("{} was deprecated and will be removed in a future release;"
-          + " setting its replacement {} instead", original, replacement);
-    });
+    property = DeprecatedPropertyUtil.getReplacementName(property,
+        (log, replacement) -> log.warn("{} was deprecated and will be removed in a future release;"
+            + " setting its replacement {} instead", original, replacement));
 
     if (!Property.isValidZooPropertyKey(property)) {
       IllegalArgumentException iae =
@@ -69,26 +65,21 @@ public class SystemPropUtil {
       log.debug("Attempted to set zookeeper property.  Value is either null or invalid", iae);
       throw iae;
     }
-
-    // create the zk node for this property and set it's data to the specified value
-    String zPath = context.getZooKeeperRoot() + Constants.ZCONFIG + "/" + property;
-
-    context.getZooReaderWriter().putPersistentData(zPath, value.getBytes(UTF_8),
-        NodeExistsPolicy.OVERWRITE);
+    context.getPropStore().putAll(SystemPropKey.of(context), Map.of(property, value));
   }
 
-  public static void removeSystemProperty(ServerContext context, String property)
-      throws InterruptedException, KeeperException {
-    String resolved = DeprecatedPropertyUtil.getReplacementName(property, (log, replacement) -> {
-      log.warn("{} was deprecated and will be removed in a future release; assuming user meant"
-          + " its replacement {} and will remove that instead", property, replacement);
-    });
+  public static void removeSystemProperty(ServerContext context, String property) {
+    String resolved =
+        DeprecatedPropertyUtil
+            .getReplacementName(property,
+                (log, replacement) -> log.warn(
+                    "{} was deprecated and will be removed in a future release; assuming user meant"
+                        + " its replacement {} and will remove that instead",
+                    property, replacement));
     removePropWithoutDeprecationWarning(context, resolved);
   }
 
-  public static void removePropWithoutDeprecationWarning(ServerContext context, String property)
-      throws InterruptedException, KeeperException {
-    String zPath = context.getZooKeeperRoot() + Constants.ZCONFIG + "/" + property;
-    context.getZooReaderWriter().recursiveDelete(zPath, NodeMissingPolicy.FAIL);
+  public static void removePropWithoutDeprecationWarning(ServerContext context, String property) {
+    context.getPropStore().removeProperties(SystemPropKey.of(context), List.of(property));
   }
 }
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/TablePropUtil.java b/server/base/src/main/java/org/apache/accumulo/server/util/TablePropUtil.java
index 8980c6b9b5..b3fb54be3b 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/TablePropUtil.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/TablePropUtil.java
@@ -18,48 +18,41 @@
  */
 package org.apache.accumulo.server.util;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
 
-import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.data.TableId;
-import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
-import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeExistsPolicy;
-import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeMissingPolicy;
 import org.apache.accumulo.server.ServerContext;
-import org.apache.zookeeper.KeeperException;
+import org.apache.accumulo.server.conf.store.TablePropKey;
 
-public class TablePropUtil {
+public class TablePropUtil extends PropUtil<TableId> {
 
-  public static boolean setTableProperty(ServerContext context, TableId tableId, String property,
-      String value) throws KeeperException, InterruptedException {
-    return setTableProperty(context.getZooReaderWriter(), context.getZooKeeperRoot(), tableId,
-        property, value);
+  public TablePropUtil(ServerContext context) {
+    super(context);
   }
 
-  public static boolean setTableProperty(ZooReaderWriter zoo, String zkRoot, TableId tableId,
-      String property, String value) throws KeeperException, InterruptedException {
-    if (!Property.isTablePropertyValid(property, value))
-      return false;
+  /**
+   * Helper method to set provided properties for the provided table.
+   *
+   * @throws IllegalStateException
+   *           if an underlying exception (KeeperException, InterruptException) or other failure to
+   *           read properties from the cache / backend store
+   */
+  @Override
+  public void setProperties(TableId tableId, Map<String,String> props) {
+    Map<String,String> tempProps = new HashMap<>(props);
+    // TODO reconcile with NamespacePropUtil see https://github.com/apache/accumulo/issues/2633
+    tempProps.entrySet().removeIf(e -> !Property.isTablePropertyValid(e.getKey(), e.getValue()));
+
+    context.getPropStore().putAll(TablePropKey.of(context, tableId), props);
 
-    // create the zk node for per-table properties for this table if it doesn't already exist
-    String zkTablePath = getTablePath(zkRoot, tableId);
-    zoo.putPersistentData(zkTablePath, new byte[0], NodeExistsPolicy.SKIP);
-
-    // create the zk node for this property and set it's data to the specified value
-    String zPath = zkTablePath + "/" + property;
-    zoo.putPersistentData(zPath, value.getBytes(UTF_8), NodeExistsPolicy.OVERWRITE);
-
-    return true;
   }
 
-  public static void removeTableProperty(ServerContext context, TableId tableId, String property)
-      throws InterruptedException, KeeperException {
-    String zPath = getTablePath(context.getZooKeeperRoot(), tableId) + "/" + property;
-    context.getZooReaderWriter().recursiveDelete(zPath, NodeMissingPolicy.SKIP);
+  @Override
+  public void removeProperties(TableId tableId, Collection<String> propertyNames) {
+    context.getPropStore().removeProperties(TablePropKey.of(context, tableId), propertyNames);
   }
 
-  private static String getTablePath(String zkRoot, TableId tableId) {
-    return zkRoot + Constants.ZTABLES + "/" + tableId.canonical() + Constants.ZTABLE_CONF;
-  }
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/MockServerContext.java b/server/base/src/test/java/org/apache/accumulo/server/MockServerContext.java
index f798b5e846..a3ab1350b8 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/MockServerContext.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/MockServerContext.java
@@ -18,6 +18,7 @@
  */
 package org.apache.accumulo.server;
 
+import static org.easymock.EasyMock.createMock;
 import static org.easymock.EasyMock.expect;
 
 import java.util.Properties;
@@ -27,6 +28,8 @@ import org.apache.accumulo.core.conf.DefaultConfiguration;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.crypto.CryptoServiceFactory;
 import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.server.conf.store.PropStore;
 import org.easymock.EasyMock;
 
 /**
@@ -54,4 +57,14 @@ public class MockServerContext {
     return sc;
   }
 
+  public static ServerContext getMockContextWithPropStore(final InstanceId instanceID,
+      ZooReaderWriter zrw, PropStore propStore) {
+
+    ServerContext sc = createMock(ServerContext.class);
+    expect(sc.getInstanceID()).andReturn(instanceID).anyTimes();
+    expect(sc.getZooReaderWriter()).andReturn(zrw).anyTimes();
+    expect(sc.getZooKeeperRoot()).andReturn("/accumulo/" + instanceID).anyTimes();
+    expect(sc.getPropStore()).andReturn(propStore).anyTimes();
+    return sc;
+  }
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/NamespaceConfigurationTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/NamespaceConfigurationTest.java
index dcc6286760..c57d4248de 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/NamespaceConfigurationTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/NamespaceConfigurationTest.java
@@ -18,133 +18,158 @@
  */
 package org.apache.accumulo.server.conf;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.accumulo.server.MockServerContext.getMockContextWithPropStore;
+import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.createMock;
 import static org.easymock.EasyMock.eq;
 import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
 import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
 import static org.easymock.EasyMock.verify;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 
-import java.util.List;
+import java.time.Instant;
 import java.util.Map;
 import java.util.UUID;
 import java.util.function.Predicate;
 
-import org.apache.accumulo.core.Constants;
 import org.apache.accumulo.core.clientImpl.Namespace;
 import org.apache.accumulo.core.conf.AccumuloConfiguration;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.data.InstanceId;
 import org.apache.accumulo.core.data.NamespaceId;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooCacheFactory;
-import org.apache.accumulo.fate.zookeeper.ZooUtil;
-import org.apache.accumulo.server.MockServerContext;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
 import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 public class NamespaceConfigurationTest {
   private static final NamespaceId NSID = NamespaceId.of("namespace");
-  private static final String ZOOKEEPERS = "localhost";
-  private static final int ZK_SESSION_TIMEOUT = 120000;
 
   private InstanceId iid;
   private ServerContext context;
   private AccumuloConfiguration parent;
-  private ZooCacheFactory zcf;
-  private ZooCache zc;
-  private NamespaceConfiguration c;
+  private NamespaceConfiguration nsConfig;
+
+  private PropStore propStore;
 
   @BeforeEach
   public void setUp() {
     iid = InstanceId.of(UUID.randomUUID());
 
-    context = MockServerContext.getWithZK(iid, ZOOKEEPERS, ZK_SESSION_TIMEOUT);
+    propStore = createMock(ZooPropStore.class);
+
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+
+    ZooReaderWriter zrw = createMock(ZooReaderWriter.class);
+
+    context = getMockContextWithPropStore(iid, zrw, propStore);
+
     parent = createMock(AccumuloConfiguration.class);
-    replay(context);
+    reset(propStore);
+
+    var nsCacheKey = NamespacePropKey.of(iid, NSID);
+    expect(propStore.get(eq(nsCacheKey))).andReturn(new VersionedProperties(123, Instant.now(),
+        Map.of(Property.INSTANCE_SECRET.getKey(), "sekrit"))).anyTimes();
+    propStore.registerAsListener(eq(nsCacheKey), anyObject());
+    expectLastCall().anyTimes();
 
-    c = new NamespaceConfiguration(NSID, context, parent);
-    zcf = createMock(ZooCacheFactory.class);
-    c.setZooCacheFactory(zcf);
+    replay(propStore, context);
 
-    zc = createMock(ZooCache.class);
-    expect(zcf.getZooCache(eq(ZOOKEEPERS), eq(ZK_SESSION_TIMEOUT))).andReturn(zc);
-    replay(zcf);
+    nsConfig = new NamespaceConfiguration(context, NSID, parent);
   }
 
   @Test
   public void testGetters() {
-    assertEquals(NSID, c.getNamespaceId());
-    assertEquals(parent, c.getParent());
+    NamespaceConfiguration nsConfig = new NamespaceConfiguration(context, NSID, parent);
+    assertEquals(NSID, nsConfig.getNamespaceId());
+    assertEquals(parent, nsConfig.getParent());
   }
 
   @Test
   public void testGet_InZK() {
-    Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + NSID
-        + Constants.ZNAMESPACE_CONF + "/" + p.getKey())).andReturn("sekrit".getBytes(UTF_8));
-    replay(zc);
-    assertEquals("sekrit", c.get(Property.INSTANCE_SECRET));
+    nsConfig = new NamespaceConfiguration(context, NSID, parent);
+
+    assertEquals("sekrit", nsConfig.get(Property.INSTANCE_SECRET));
+
+    verify(propStore);
   }
 
   @Test
   public void testGet_InParent() {
     Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + NSID
-        + Constants.ZNAMESPACE_CONF + "/" + p.getKey())).andReturn(null);
-    replay(zc);
-    expect(parent.get(p)).andReturn("sekrit");
+
+    expect(parent.get(p.getKey())).andReturn("sekrit");
     replay(parent);
-    assertEquals("sekrit", c.get(Property.INSTANCE_SECRET));
+
+    nsConfig = new NamespaceConfiguration(context, NSID, parent);
+
+    assertEquals("sekrit", nsConfig.get(Property.INSTANCE_SECRET));
+
+    // TODO Need to check parent and accessor usage
   }
 
   @Test
   public void testGet_SkipParentIfAccumuloNS() {
-    c = new NamespaceConfiguration(Namespace.ACCUMULO.id(), context, parent);
-    c.setZooCacheFactory(zcf);
-    Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + Namespace.ACCUMULO.id()
-        + Constants.ZNAMESPACE_CONF + "/" + p.getKey())).andReturn(null);
-    replay(zc);
-    assertNull(c.get(Property.INSTANCE_SECRET));
+    reset(propStore);
+    var nsPropKey = NamespacePropKey.of(iid, Namespace.ACCUMULO.id());
+    expect(propStore.get(eq(nsPropKey))).andReturn(new VersionedProperties(Map.of("a", "b")))
+        .anyTimes();
+    propStore.registerAsListener(eq(nsPropKey), anyObject());
+    expectLastCall().anyTimes();
+    replay(propStore);
+
+    NamespaceConfiguration accumuloNsConfig =
+        new NamespaceConfiguration(context, Namespace.ACCUMULO.id(), parent);
+    assertNull(accumuloNsConfig.get(Property.INSTANCE_SECRET));
   }
 
   @Test
   public void testGetProperties() {
     Predicate<String> all = x -> true;
     Map<String,String> props = new java.util.HashMap<>();
+    props.put("dog", "bark");
+    props.put("cat", "meow");
     parent.getProperties(props, all);
+
     replay(parent);
-    List<String> children = new java.util.ArrayList<>();
-    children.add("foo");
-    children.add("ding");
-    expect(zc.getChildren(
-        ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + NSID + Constants.ZNAMESPACE_CONF))
-            .andReturn(children);
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + NSID
-        + Constants.ZNAMESPACE_CONF + "/foo")).andReturn("bar".getBytes(UTF_8));
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + NSID
-        + Constants.ZNAMESPACE_CONF + "/ding")).andReturn("dong".getBytes(UTF_8));
-    replay(zc);
-    c.getProperties(props, all);
-    assertEquals(2, props.size());
+    reset(propStore);
+
+    var nsPropKey = NamespacePropKey.of(iid, NSID);
+    expect(propStore.get(eq(nsPropKey)))
+        .andReturn(
+            new VersionedProperties(123, Instant.now(), Map.of("foo", "bar", "tick", "tock")))
+        .anyTimes();
+    propStore.registerAsListener(eq(nsPropKey), anyObject());
+    expectLastCall().anyTimes();
+
+    replay(propStore);
+
+    nsConfig = new NamespaceConfiguration(context, NSID, parent);
+
+    nsConfig.getProperties(props, all);
+    assertEquals(4, props.size());
     assertEquals("bar", props.get("foo"));
-    assertEquals("dong", props.get("ding"));
+    assertEquals("tock", props.get("tick"));
+    assertEquals("bark", props.get("dog"));
+    assertEquals("meow", props.get("cat"));
   }
 
   @Test
   public void testInvalidateCache() {
-    // need to do a get so the accessor is created
-    Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZNAMESPACES + "/" + NSID
-        + Constants.ZNAMESPACE_CONF + "/" + p.getKey())).andReturn("sekrit".getBytes(UTF_8));
-    zc.clear();
-    replay(zc);
-    c.get(Property.INSTANCE_SECRET);
-    c.invalidateCache();
-    verify(zc);
+    nsConfig = new NamespaceConfiguration(context, NSID, parent);
+
+    var value = nsConfig.get(Property.INSTANCE_SECRET);
+    assertEquals("sekrit", value);
+
+    nsConfig.invalidateCache();
+    verify(propStore);
   }
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/RuntimeFixedPropertiesTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/RuntimeFixedPropertiesTest.java
new file mode 100644
index 0000000000..58ff7fb154
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/RuntimeFixedPropertiesTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf;
+
+import static org.apache.accumulo.core.conf.Property.GC_PORT;
+import static org.apache.accumulo.core.conf.Property.TSERV_NATIVEMAP_ENABLED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.core.conf.SiteConfiguration;
+import org.junit.jupiter.api.Test;
+
+public class RuntimeFixedPropertiesTest {
+
+  @Test
+  public void defaultTest() {
+    var siteConfig = SiteConfiguration.fromEnv().build();
+    Map<String,String> storedProps = new HashMap<>();
+
+    RuntimeFixedProperties fixed = new RuntimeFixedProperties(storedProps, siteConfig);
+
+    assertEquals("true", fixed.get(Property.TSERV_NATIVEMAP_ENABLED));
+  }
+
+  @Test
+  public void storedOverrideTest() {
+    var siteConfig = SiteConfiguration.fromEnv().build();
+    Map<String,String> storedProps = Map.of("tserver.memory.maps.native.enabled", "false");
+
+    RuntimeFixedProperties fixed = new RuntimeFixedProperties(storedProps, siteConfig);
+
+    assertEquals("false", fixed.get(Property.TSERV_NATIVEMAP_ENABLED));
+  }
+
+  @Test
+  public void allTest() {
+    var siteConfig = SiteConfiguration.fromEnv().build();
+    Map<String,String> storedProps = new HashMap<>();
+
+    RuntimeFixedProperties fixed = new RuntimeFixedProperties(storedProps, siteConfig);
+
+    assertEquals(Property.fixedProperties.size(), fixed.getAll().size());
+  }
+
+  @Test
+  public void changedTest() {
+    var siteConfig = SiteConfiguration.fromEnv().build();
+    Map<String,String> storedProps = Map.of(TSERV_NATIVEMAP_ENABLED.getKey(), "false");
+
+    RuntimeFixedProperties fixed = new RuntimeFixedProperties(storedProps, siteConfig);
+
+    // prop removed - changed
+    assertTrue(fixed.hasChanged(Map.of()));
+
+    // prop modified - changed
+    assertTrue(fixed.hasChanged(Map.of(TSERV_NATIVEMAP_ENABLED.getKey(), "true")));
+
+    // prop added - changed
+    assertTrue(fixed
+        .hasChanged(Map.of(TSERV_NATIVEMAP_ENABLED.getKey(), "false", GC_PORT.getKey(), "1234")));
+
+    // same - no change
+    assertFalse(fixed.hasChanged(storedProps));
+
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/ServerConfigurationFactoryTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/ServerConfigurationFactoryTest.java
index 1a7ad40410..629aa1a51d 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/ServerConfigurationFactoryTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/ServerConfigurationFactoryTest.java
@@ -22,95 +22,103 @@ import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.createMock;
 import static org.easymock.EasyMock.eq;
 import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
 import static org.easymock.EasyMock.replay;
 import static org.easymock.EasyMock.verify;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 
-import org.apache.accumulo.core.conf.AccumuloConfiguration;
+import java.util.Map;
+import java.util.UUID;
+
 import org.apache.accumulo.core.conf.DefaultConfiguration;
 import org.apache.accumulo.core.conf.SiteConfiguration;
 import org.apache.accumulo.core.data.InstanceId;
 import org.apache.accumulo.core.data.NamespaceId;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooCacheFactory;
-import org.apache.accumulo.server.MockServerContext;
+import org.apache.accumulo.core.data.TableId;
 import org.apache.accumulo.server.ServerContext;
-import org.junit.jupiter.api.AfterAll;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
 import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 public class ServerConfigurationFactoryTest {
   private static final String ZK_HOST = "localhost";
-  private static final int ZK_TIMEOUT = 120000;
-  private static final InstanceId IID = InstanceId.of("iid");
-
-  // use the same mock ZooCacheFactory and ZooCache for all tests
-  private static ZooCacheFactory zcf;
-  private static ZooCache zc;
-  private static SiteConfiguration siteConfig = SiteConfiguration.empty().build();
-
-  @BeforeAll
-  public static void setUpClass() {
-    zcf = createMock(ZooCacheFactory.class);
-    zc = createMock(ZooCache.class);
-    expect(zcf.getNewZooCache(eq(ZK_HOST), eq(ZK_TIMEOUT))).andReturn(zc).anyTimes();
-    expect(zcf.getZooCache(ZK_HOST, ZK_TIMEOUT)).andReturn(zc).anyTimes();
-    replay(zcf);
-
-    expect(zc.getChildren(anyObject(String.class))).andReturn(null).anyTimes();
-    replay(zc);
-  }
-
-  @AfterAll
-  public static void verifyClassMocks() {
-    verify(zcf, zc);
-  }
+  private static final int ZK_TIMEOUT = 120_000;
+  private static final InstanceId IID = InstanceId.of(UUID.randomUUID());
+  private static final TableId TID = TableId.of("TABLE");
+  private static final NamespaceId NSID = NamespaceId.of("NAMESPACE");
+  private static final SiteConfiguration siteConfig = SiteConfiguration.empty().build();
 
+  private PropStore propStore;
   private ServerContext context;
   private ServerConfigurationFactory scf;
 
   @BeforeEach
   public void setUp() {
-    context = MockServerContext.getWithZK(IID, ZK_HOST, ZK_TIMEOUT);
-    replay(context);
-    scf = new ServerConfigurationFactory(context, siteConfig);
-    scf.setZooCacheFactory(zcf);
+    propStore = createMock(ZooPropStore.class);
+    expect(propStore.get(eq(SystemPropKey.of(IID)))).andReturn(new VersionedProperties(Map.of()))
+        .anyTimes();
+    expect(propStore.get(eq(TablePropKey.of(IID, TID))))
+        .andReturn(new VersionedProperties(Map.of())).anyTimes();
+    expect(propStore.get(eq(NamespacePropKey.of(IID, NSID))))
+        .andReturn(new VersionedProperties(Map.of())).anyTimes();
+
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+
+    context = createMock(ServerContext.class);
+    expect(context.getZooKeeperRoot()).andReturn("/accumulo/" + IID).anyTimes();
+    expect(context.getInstanceID()).andReturn(IID).anyTimes();
+    expect(context.getZooKeepers()).andReturn(ZK_HOST).anyTimes();
+    expect(context.getZooKeepersSessionTimeOut()).andReturn(ZK_TIMEOUT).anyTimes();
+    expect(context.getSiteConfiguration()).andReturn(siteConfig).anyTimes();
+    expect(context.tableNodeExists(TID)).andReturn(true).anyTimes();
+    expect(context.getPropStore()).andReturn(propStore).anyTimes();
+
+    scf = new ServerConfigurationFactory(context, siteConfig) {
+      @Override
+      public NamespaceConfiguration getNamespaceConfigurationForTable(TableId tableId) {
+        if (tableId.equals(TID)) {
+          return getNamespaceConfiguration(NSID);
+        }
+        throw new UnsupportedOperationException();
+      }
+    };
+
+    replay(propStore, context);
   }
 
   @AfterEach
   public void verifyMocks() {
-    verify(context);
+    verify(propStore, context);
   }
 
   @Test
-  public void testGetDefaultConfiguration() {
-    DefaultConfiguration c = scf.getDefaultConfiguration();
-    assertNotNull(c);
+  public void testGetters() {
+    assertSame(DefaultConfiguration.getInstance(), scf.getDefaultConfiguration());
+    assertSame(siteConfig, scf.getSiteConfiguration());
+    assertNotNull(scf.getSystemConfiguration());
   }
 
   @Test
-  public void testGetSiteConfiguration() {
-    var c = scf.getSiteConfiguration();
-    assertNotNull(c);
+  public void testGetNamespaceConfiguration() {
+    NamespaceConfiguration namespaceConfigSingleton = scf.getNamespaceConfiguration(NSID);
+    assertEquals(NSID, namespaceConfigSingleton.getNamespaceId());
+    assertSame(namespaceConfigSingleton, scf.getNamespaceConfiguration(NSID));
   }
 
   @Test
-  public void testGetConfiguration() {
-    AccumuloConfiguration c = scf.getSystemConfiguration();
-    assertNotNull(c);
-  }
-
-  private static final NamespaceId NSID = NamespaceId.of("NAMESPACE");
-
-  @Test
-  public void testGetNamespaceConfiguration() {
-    NamespaceConfiguration c = scf.getNamespaceConfiguration(NSID);
-    assertEquals(NSID, c.getNamespaceId());
-    assertSame(c, scf.getNamespaceConfiguration(NSID));
+  public void testGetTableConfiguration() {
+    TableConfiguration tableConfigSingleton = scf.getTableConfiguration(TID);
+    assertEquals(TID, tableConfigSingleton.getTableId());
+    assertSame(tableConfigSingleton, scf.getTableConfiguration(TID));
   }
 
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/SystemConfigurationTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/SystemConfigurationTest.java
new file mode 100644
index 0000000000..44ac62deae
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/SystemConfigurationTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf;
+
+import static org.apache.accumulo.core.conf.Property.GC_PORT;
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_ENABLED;
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_SIZE;
+import static org.apache.accumulo.core.conf.Property.TABLE_DURABILITY;
+import static org.apache.accumulo.core.conf.Property.TSERV_CLIENTPORT;
+import static org.apache.accumulo.core.conf.Property.TSERV_SCAN_MAX_OPENFILES;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.accumulo.core.conf.ConfigurationCopy;
+import org.apache.accumulo.core.conf.SiteConfiguration;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class SystemConfigurationTest {
+
+  private InstanceId instanceId;
+
+  private PropStore propStore;
+
+  private SystemConfiguration sysConfig;
+
+  @BeforeEach
+  public void initMocks() {
+    instanceId = InstanceId.of(UUID.randomUUID());
+    ServerContext context = createMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+    propStore = createMock(ZooPropStore.class);
+    expect(context.getPropStore()).andReturn(propStore).anyTimes();
+    var siteConfig = SiteConfiguration.empty().build();
+    expect(context.getSiteConfiguration()).andReturn(siteConfig).anyTimes();
+
+    replay(context); // prop store is read from context.
+
+    // this test is ignoring listeners
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+
+    var sysPropKey = SystemPropKey.of(instanceId);
+    VersionedProperties sysProps =
+        new VersionedProperties(1, Instant.now(), Map.of(GC_PORT.getKey(), "1234",
+            TSERV_SCAN_MAX_OPENFILES.getKey(), "19", TABLE_BLOOM_ENABLED.getKey(), "true"));
+    expect(propStore.get(eq(sysPropKey))).andReturn(sysProps).times(2);
+    replay(propStore);
+
+    ConfigurationCopy defaultConfig =
+        new ConfigurationCopy(Map.of(TABLE_BLOOM_SIZE.getKey(), TABLE_BLOOM_SIZE.getDefaultValue(),
+            TABLE_DURABILITY.getKey(), TABLE_DURABILITY.getDefaultValue()));
+
+    sysConfig = new SystemConfiguration(context, sysPropKey, defaultConfig);
+  }
+
+  @Test
+  public void testFromDefault() {
+    assertEquals("true", sysConfig.get(TABLE_BLOOM_ENABLED));
+    assertTrue(sysConfig.isPropertySet(TABLE_BLOOM_ENABLED));
+  }
+
+  @Test
+  public void testFromFixed() {
+
+    var sysPropKey = SystemPropKey.of(instanceId);
+
+    assertEquals("9997", sysConfig.get(TSERV_CLIENTPORT)); // default
+    assertEquals("1234", sysConfig.get(GC_PORT)); // fixed sys config
+    assertEquals("19", sysConfig.get(TSERV_SCAN_MAX_OPENFILES)); // fixed sys config
+    assertEquals("true", sysConfig.get(TABLE_BLOOM_ENABLED)); // sys config
+    assertEquals(TABLE_BLOOM_SIZE.getDefaultValue(), sysConfig.get(TABLE_BLOOM_SIZE)); // default
+
+    assertTrue(sysConfig.isPropertySet(TSERV_CLIENTPORT)); // default
+    assertTrue(sysConfig.isPropertySet(GC_PORT)); // fixed sys config
+    assertTrue(sysConfig.isPropertySet(TSERV_SCAN_MAX_OPENFILES)); // fixed sys config
+    assertTrue(sysConfig.isPropertySet(TABLE_BLOOM_ENABLED)); // sys config
+    assertTrue(sysConfig.isPropertySet(TABLE_BLOOM_SIZE)); // default
+
+    reset(propStore);
+
+    VersionedProperties sysUpdateProps = new VersionedProperties(2, Instant.now(),
+        Map.of(GC_PORT.getKey(), "3456", TSERV_SCAN_MAX_OPENFILES.getKey(), "27",
+            TABLE_BLOOM_ENABLED.getKey(), "false", TABLE_BLOOM_SIZE.getKey(), "2048"));
+    expect(propStore.get(eq(sysPropKey))).andReturn(sysUpdateProps).anyTimes();
+    replay(propStore);
+
+    sysConfig.zkChangeEvent(sysPropKey);
+
+    assertEquals("9997", sysConfig.get(TSERV_CLIENTPORT)); // default
+    assertEquals("1234", sysConfig.get(GC_PORT)); // fixed sys config
+    assertEquals("19", sysConfig.get(TSERV_SCAN_MAX_OPENFILES)); // fixed sys config
+    assertEquals("false", sysConfig.get(TABLE_BLOOM_ENABLED)); // sys config
+    assertEquals("2048", sysConfig.get(TABLE_BLOOM_SIZE)); // default
+
+    assertTrue(sysConfig.isPropertySet(TSERV_CLIENTPORT)); // default
+    assertTrue(sysConfig.isPropertySet(GC_PORT)); // fixed sys config
+    assertTrue(sysConfig.isPropertySet(TSERV_SCAN_MAX_OPENFILES)); // fixed sys config
+    assertTrue(sysConfig.isPropertySet(TABLE_BLOOM_ENABLED)); // sys config
+    assertTrue(sysConfig.isPropertySet(TABLE_BLOOM_SIZE)); // default
+
+  }
+
+  @Test
+  public void isPropertySet() {
+    // TODO - add test
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/TableConfigurationTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/TableConfigurationTest.java
index c92b06e129..448d898a3b 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/TableConfigurationTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/TableConfigurationTest.java
@@ -18,119 +18,263 @@
  */
 package org.apache.accumulo.server.conf;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.accumulo.core.conf.Property.INSTANCE_SECRET;
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_ENABLED;
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_SIZE;
+import static org.apache.accumulo.core.conf.Property.TABLE_DURABILITY;
+import static org.apache.accumulo.core.conf.Property.TABLE_FILE_MAX;
+import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.createMock;
 import static org.easymock.EasyMock.eq;
 import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
 import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
 import static org.easymock.EasyMock.verify;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
-import java.util.List;
+import java.time.Instant;
 import java.util.Map;
 import java.util.UUID;
 import java.util.function.Predicate;
 
-import org.apache.accumulo.core.Constants;
+import org.apache.accumulo.core.conf.ConfigurationCopy;
 import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.core.conf.SiteConfiguration;
 import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
 import org.apache.accumulo.core.data.TableId;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.apache.accumulo.fate.zookeeper.ZooCacheFactory;
-import org.apache.accumulo.fate.zookeeper.ZooUtil;
-import org.apache.accumulo.server.MockServerContext;
+import org.apache.accumulo.core.iterators.IteratorUtil;
 import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class TableConfigurationTest {
-  private static final TableId TID = TableId.of("table");
-  private static final String ZOOKEEPERS = "localhost";
-  private static final int ZK_SESSION_TIMEOUT = 120000;
 
-  private InstanceId iid;
-  private ServerContext context;
-  private NamespaceConfiguration parent;
-  private ZooCacheFactory zcf;
-  private ZooCache zc;
-  private TableConfiguration c;
+  private static final TableId TID = TableId.of("3");
+  private static final NamespaceId NID = NamespaceId.of("2");
+
+  private InstanceId instanceId;
+
+  private PropStore propStore;
+
+  private TableConfiguration tableConfig;
+  private NamespaceConfiguration nsConfig;
 
   @BeforeEach
-  public void setUp() {
-    iid = InstanceId.of(UUID.randomUUID());
-    context = MockServerContext.getWithZK(iid, ZOOKEEPERS, ZK_SESSION_TIMEOUT);
-    replay(context);
-
-    parent = createMock(NamespaceConfiguration.class);
-    c = new TableConfiguration(context, TID, parent);
-    zcf = createMock(ZooCacheFactory.class);
-    c.setZooCacheFactory(zcf);
-
-    zc = createMock(ZooCache.class);
-    expect(zcf.getZooCache(eq(ZOOKEEPERS), eq(ZK_SESSION_TIMEOUT))).andReturn(zc);
-    replay(zcf);
+  public void initMocks() {
+    instanceId = InstanceId.of(UUID.randomUUID());
+    ServerContext context = createMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+    propStore = createMock(ZooPropStore.class);
+    expect(context.getPropStore()).andReturn(propStore).anyTimes();
+    var siteConfig = SiteConfiguration.empty().build();
+    expect(context.getSiteConfiguration()).andReturn(siteConfig).anyTimes();
+
+    replay(context); // prop store is read from context.
+
+    // this test is ignoring listeners
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+
+    var sysPropKey = SystemPropKey.of(instanceId);
+    VersionedProperties sysProps =
+        new VersionedProperties(1, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true"));
+    expect(propStore.get(eq(sysPropKey))).andReturn(sysProps).times(2);
+
+    var nsPropKey = NamespacePropKey.of(instanceId, NID);
+    VersionedProperties nsProps = new VersionedProperties(2, Instant.now(),
+        Map.of(TABLE_FILE_MAX.getKey(), "21", TABLE_BLOOM_ENABLED.getKey(), "false"));
+    expect(propStore.get(eq(nsPropKey))).andReturn(nsProps).once();
+
+    var tablePropKey = TablePropKey.of(instanceId, TID);
+    VersionedProperties tableProps =
+        new VersionedProperties(3, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true"));
+    expect(propStore.get(eq(tablePropKey))).andReturn(tableProps).once();
+
+    ConfigurationCopy defaultConfig =
+        new ConfigurationCopy(Map.of(TABLE_BLOOM_SIZE.getKey(), TABLE_BLOOM_SIZE.getDefaultValue(),
+            TABLE_DURABILITY.getKey(), TABLE_DURABILITY.getDefaultValue()));
+
+    replay(propStore);
+
+    SystemConfiguration sysConfig = new SystemConfiguration(context, sysPropKey, defaultConfig);
+    NamespaceId nsid = nsPropKey.getId();
+    if (nsid == null) {
+      throw new IllegalStateException("missing test namespaceId");
+    }
+    nsConfig = new NamespaceConfiguration(context, nsid, sysConfig);
+
+    TableId tid = tablePropKey.getId();
+    if (tid == null) {
+      throw new IllegalStateException("missing test tableId");
+    }
+    tableConfig = new TableConfiguration(context, tid, nsConfig);
+
   }
 
   @Test
   public void testGetters() {
-    assertEquals(TID, c.getTableId());
-    assertEquals(parent, c.getParent());
+    assertEquals(TID, tableConfig.getTableId());
+    assertEquals(nsConfig, tableConfig.getParent());
   }
 
   @Test
   public void testGet_InZK() {
     Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZTABLES + "/" + TID + Constants.ZTABLE_CONF + "/"
-        + p.getKey())).andReturn("sekrit".getBytes(UTF_8));
-    replay(zc);
-    assertEquals("sekrit", c.get(Property.INSTANCE_SECRET));
+    reset(propStore);
+
+    var propKey = TablePropKey.of(instanceId, TID);
+    expect(propStore.get(eq(propKey)))
+        .andReturn(new VersionedProperties(37, Instant.now(), Map.of(p.getKey(), "sekrit")))
+        .anyTimes();
+    replay(propStore);
+
+    tableConfig.zkChangeEvent(propKey);
+
+    assertEquals("sekrit", tableConfig.get(p));
+
+    verify(propStore);
   }
 
   @Test
   public void testGet_InParent() {
-    Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZTABLES + "/" + TID + Constants.ZTABLE_CONF + "/"
-        + p.getKey())).andReturn(null);
-    replay(zc);
-    expect(parent.get(p)).andReturn("sekrit");
-    replay(parent);
-    assertEquals("sekrit", c.get(Property.INSTANCE_SECRET));
+
+    String expectedPass = "aPassword1";
+
+    reset(propStore);
+    expect(propStore.get(eq(NamespacePropKey.of(instanceId, NID))))
+        .andReturn(new VersionedProperties(13, Instant.now(), Map.of(TABLE_FILE_MAX.getKey(), "123",
+            Property.INSTANCE_SECRET.getKey(), expectedPass)))
+        .anyTimes();
+    expect(propStore.get(eq(TablePropKey.of(instanceId, TID))))
+        .andReturn(new VersionedProperties(Map.of())).anyTimes();
+    replay(propStore);
+
+    nsConfig.zkChangeEvent(NamespacePropKey.of(instanceId, NID));
+
+    assertEquals("123", tableConfig.get(TABLE_FILE_MAX)); // from ns
+    assertEquals("aPassword1", tableConfig.get(INSTANCE_SECRET.getKey())); // from sys
+
+    verify(propStore);
   }
 
   @Test
   public void testGetProperties() {
     Predicate<String> all = x -> true;
+
+    reset(propStore);
+
+    expect(propStore.get(eq(SystemPropKey.of(instanceId))))
+        .andReturn(new VersionedProperties(1, Instant.now(), Map.of()));
+
+    expect(propStore.get(eq(NamespacePropKey.of(instanceId, NID))))
+        .andReturn(new VersionedProperties(2, Instant.now(), Map.of("dog", "bark", "cat", "meow")));
+
+    expect(propStore.get(eq(TablePropKey.of(instanceId, TID))))
+        .andReturn(new VersionedProperties(4, Instant.now(), Map.of("foo", "bar", "tick", "tock")))
+        .anyTimes();
+
+    replay(propStore);
+
     Map<String,String> props = new java.util.HashMap<>();
-    parent.getProperties(props, all);
-    replay(parent);
-    List<String> children = new java.util.ArrayList<>();
-    children.add("foo");
-    children.add("ding");
-    expect(zc
-        .getChildren(ZooUtil.getRoot(iid) + Constants.ZTABLES + "/" + TID + Constants.ZTABLE_CONF))
-            .andReturn(children);
-    expect(zc
-        .get(ZooUtil.getRoot(iid) + Constants.ZTABLES + "/" + TID + Constants.ZTABLE_CONF + "/foo"))
-            .andReturn("bar".getBytes(UTF_8));
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZTABLES + "/" + TID + Constants.ZTABLE_CONF + "/"
-        + "ding")).andReturn("dong".getBytes(UTF_8));
-    replay(zc);
-    c.getProperties(props, all);
-    assertEquals(2, props.size());
+
+    tableConfig.zkChangeEvent(TablePropKey.of(instanceId, TID));
+    nsConfig.zkChangeEvent(NamespacePropKey.of(instanceId, NID));
+
+    tableConfig.getProperties(props, all);
+
+    log.info("Props returned: {}", props);
+    assertEquals(7, props.size());
+    assertEquals(TABLE_BLOOM_SIZE.getDefaultValue(), props.get(TABLE_BLOOM_SIZE.getKey())); // system
+    assertEquals(TABLE_DURABILITY.getDefaultValue(), props.get(TABLE_DURABILITY.getKey())); // system
+    assertEquals("true", props.get(TABLE_BLOOM_ENABLED.getKey())); // system
+    assertEquals("bar", props.get("foo")); // table
+    assertEquals("tock", props.get("tick")); // table
+    assertEquals("bark", props.get("dog")); // namespace
+    assertEquals("meow", props.get("cat")); // namespace
+  }
+
+  @Test
+  public void testGetFilteredProperties() {
+    Predicate<String> filter = x -> !x.equals("filter");
+
+    reset(propStore);
+
+    expect(propStore.get(eq(SystemPropKey.of(instanceId))))
+        .andReturn(new VersionedProperties(1, Instant.now(), Map.of()));
+
+    expect(propStore.get(eq(NamespacePropKey.of(instanceId, NID))))
+        .andReturn(new VersionedProperties(2, Instant.now(),
+            Map.of("dog", "bark", "cat", "meow", "filter", "from_parent")));
+
+    expect(propStore.get(eq(TablePropKey.of(instanceId, TID)))).andReturn(new VersionedProperties(4,
+        Instant.now(), Map.of("filter", "not_returned_by_table", "foo", "bar", "tick", "tock")))
+        .anyTimes();
+
+    replay(propStore);
+
+    tableConfig.zkChangeEvent(TablePropKey.of(instanceId, TID));
+    nsConfig.zkChangeEvent(NamespacePropKey.of(instanceId, NID));
+
+    Map<String,String> props = new java.util.HashMap<>();
+
+    tableConfig.getProperties(props, filter);
+
+    log.info("Props returned: {}", props);
+    assertEquals(7, props.size());
+    assertEquals(TABLE_BLOOM_SIZE.getDefaultValue(), props.get(TABLE_BLOOM_SIZE.getKey())); // system
+    assertEquals(TABLE_DURABILITY.getDefaultValue(), props.get(TABLE_DURABILITY.getKey())); // system
+    assertEquals("true", props.get(TABLE_BLOOM_ENABLED.getKey())); // system
     assertEquals("bar", props.get("foo"));
-    assertEquals("dong", props.get("ding"));
+    assertEquals("tock", props.get("tick"));
+    assertEquals("bark", props.get("dog"));
+    assertEquals("meow", props.get("cat"));
+    assertNull(props.get("filter"));
   }
 
   @Test
   public void testInvalidateCache() {
     // need to do a get so the accessor is created
     Property p = Property.INSTANCE_SECRET;
-    expect(zc.get(ZooUtil.getRoot(iid) + Constants.ZTABLES + "/" + TID + Constants.ZTABLE_CONF + "/"
-        + p.getKey())).andReturn("sekrit".getBytes(UTF_8));
-    zc.clear();
-    replay(zc);
-    c.get(Property.INSTANCE_SECRET);
-    c.invalidateCache();
-    verify(zc);
+
+    reset(propStore);
+
+    var propKey = TablePropKey.of(instanceId, TID);
+
+    expect(propStore.get(eq(propKey)))
+        .andReturn(new VersionedProperties(23, Instant.now(), Map.of(p.getKey(), "invalid")))
+        .once();
+    expect(propStore.get(eq(propKey)))
+        .andReturn(new VersionedProperties(39, Instant.now(), Map.of(p.getKey(), "sekrit"))).once();
+
+    replay(propStore);
+
+    tableConfig.zkChangeEvent(propKey);
+    assertEquals("invalid", tableConfig.get(Property.INSTANCE_SECRET));
+
+    tableConfig.invalidateCache();
+
+    assertEquals("sekrit", tableConfig.get(Property.INSTANCE_SECRET));
+
+    verify(propStore);
+  }
+
+  private final static Logger log = LoggerFactory.getLogger(TableConfigurationTest.class);
+
+  @Test
+  public void getParsedIteratorConfig() {
+    var iterConfig = tableConfig.getParsedIteratorConfig(IteratorUtil.IteratorScope.scan);
+    assertNotNull(iterConfig);
   }
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/ZooBasedConfigurationTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/ZooBasedConfigurationTest.java
new file mode 100644
index 0000000000..851df8c3f2
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/ZooBasedConfigurationTest.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf;
+
+import static org.apache.accumulo.core.conf.Property.GC_PORT;
+import static org.apache.accumulo.core.conf.Property.MANAGER_BULK_RETRIES;
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_ENABLED;
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_SIZE;
+import static org.apache.accumulo.core.conf.Property.TABLE_DURABILITY;
+import static org.apache.accumulo.core.conf.Property.TABLE_SPLIT_THRESHOLD;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Predicate;
+
+import org.apache.accumulo.core.conf.AccumuloConfiguration;
+import org.apache.accumulo.core.conf.ConfigurationCopy;
+import org.apache.accumulo.core.conf.DefaultConfiguration;
+import org.apache.accumulo.core.conf.SiteConfiguration;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ZooBasedConfigurationTest {
+
+  private final static Logger log = LoggerFactory.getLogger(ZooBasedConfigurationTest.class);
+
+  private InstanceId instanceId;
+  private ServerContext context;
+  private PropStore propStore;
+
+  @BeforeEach
+  public void initMocks() {
+    instanceId = InstanceId.of(UUID.randomUUID());
+    context = createMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+    propStore = createMock(ZooPropStore.class);
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+    expect(context.getPropStore()).andReturn(propStore).anyTimes();
+  }
+
+  @AfterEach
+  public void verifyMocks() {
+    // verify mocks common to each test
+    verify(context, propStore);
+  }
+
+  @Test
+  public void defaultInitializationTest() {
+    replay(context, propStore);
+    assertNotNull(context.getPropStore());
+  }
+
+  @Test
+  public void defaultSysConfigTest() {
+    var sysKey = SystemPropKey.of(instanceId);
+
+    var siteConfig = SiteConfiguration.empty().build();
+    expect(context.getSiteConfiguration()).andReturn(siteConfig).anyTimes();
+
+    expect(propStore.get(eq(sysKey))).andReturn(new VersionedProperties()).once(); // default empty
+                                                                                   // sys props
+    replay(context, propStore);
+    assertNotNull(context.getPropStore());
+
+    ZooBasedConfiguration configuration = new SystemConfiguration(context, sysKey, siteConfig);
+    assertNotNull(configuration);
+
+  }
+
+  @Test
+  public void get() {
+    var sysPropKey = SystemPropKey.of(instanceId);
+
+    var siteConfig = SiteConfiguration.empty().build();
+    expect(context.getSiteConfiguration()).andReturn(siteConfig).anyTimes();
+
+    VersionedProperties vProps = new VersionedProperties(3, Instant.now(), Map
+        .of(TABLE_BLOOM_ENABLED.getKey(), "true", TABLE_SPLIT_THRESHOLD.getKey(), "int expected"));
+    expect(propStore.get(eq(sysPropKey))).andReturn(vProps).once();
+
+    replay(context, propStore);
+
+    ZooBasedConfiguration configuration = new SystemConfiguration(context, sysPropKey, siteConfig);
+
+    assertNotNull(configuration);
+    assertEquals("1G", configuration.get(TABLE_SPLIT_THRESHOLD));
+    assertEquals("true", configuration.get(TABLE_BLOOM_ENABLED));
+  }
+
+  @Test
+  public void getPropertiesTest() {
+    var tableId = TableId.of("t1");
+    var tablePropKey = TablePropKey.of(instanceId, tableId);
+
+    VersionedProperties tProps =
+        new VersionedProperties(Map.of(TABLE_BLOOM_ENABLED.getKey(), "true"));
+
+    var siteConfig = SiteConfiguration.empty().build();
+    // expect(context.getSiteConfiguration()).andReturn(siteConfig).anyTimes();
+
+    expect(propStore.get(eq(tablePropKey))).andReturn(tProps).once();
+    NamespaceId nsId = NamespaceId.of("n1");
+
+    VersionedProperties nProps =
+        new VersionedProperties(Map.of(TABLE_SPLIT_THRESHOLD.getKey(), "3G"));
+    expect(propStore.get(eq(NamespacePropKey.of(instanceId, nsId)))).andReturn(nProps).once();
+
+    replay(context, propStore);
+
+    NamespaceConfiguration nsConfig = new NamespaceConfiguration(context, nsId, siteConfig);
+
+    ZooBasedConfiguration zbc = new TableConfiguration(context, tableId, nsConfig);
+    Map<String,String> readProps = zbc.getSnapshot();
+
+    assertNotNull(zbc.getSnapshot());
+    assertEquals("true", readProps.get(TABLE_BLOOM_ENABLED.getKey()));
+    assertEquals("true", zbc.get(TABLE_BLOOM_ENABLED));
+
+    // read a property that is from the namespace config
+    assertEquals("3G", zbc.get(TABLE_SPLIT_THRESHOLD));
+    // TABLE_SPLIT_THRESHOLD
+
+    // read a fixed property from the system config
+    assertEquals("9998", zbc.get(GC_PORT));
+
+    // read a property from the sysconfig
+    assertEquals("3", zbc.get(MANAGER_BULK_RETRIES));
+  }
+
+  @Test
+  public void systemPropTest() {
+    var sysPropKey = SystemPropKey.of(instanceId);
+    VersionedProperties vProps =
+        new VersionedProperties(99, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true"));
+    expect(propStore.get(eq(sysPropKey))).andReturn(vProps).once();
+
+    replay(propStore, context);
+
+    AccumuloConfiguration defaultConfig = new ConfigurationCopy(DefaultConfiguration.getInstance());
+
+    ZooBasedConfiguration sysConfig =
+        new ZooBasedConfiguration(log, context, sysPropKey, defaultConfig);
+    assertNotNull(sysConfig);
+    assertEquals("true", sysConfig.get(TABLE_BLOOM_ENABLED));
+  }
+
+  @Test
+  public void loadFailBecauseNodeNotExistTest() {
+    var sysPropKey = SystemPropKey.of(instanceId);
+
+    expect(propStore.get(eq(sysPropKey))).andThrow(new IllegalStateException("fake no node"))
+        .once();
+
+    replay(propStore, context);
+
+    var zbc =
+        new ZooBasedConfiguration(log, context, sysPropKey, DefaultConfiguration.getInstance());
+    assertThrows(IllegalStateException.class, () -> zbc.get("anyproperty"));
+  }
+
+  /**
+   * Walk configuration tree sys -> namespace -> table and test properties are retrieved .
+   */
+  @Test
+  public void tablePropTest() {
+    var sysPropKey = SystemPropKey.of(instanceId);
+    VersionedProperties sysProps =
+        new VersionedProperties(1, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true"));
+    expect(propStore.get(eq(sysPropKey))).andReturn(sysProps).once();
+
+    var nsPropKey = NamespacePropKey.of(instanceId, NamespaceId.of("ns1"));
+    VersionedProperties nsProps =
+        new VersionedProperties(2, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "false"));
+    expect(propStore.get(eq(nsPropKey))).andReturn(nsProps).once();
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("ns1.table1"));
+    VersionedProperties tableProps =
+        new VersionedProperties(3, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true"));
+    expect(propStore.get(eq(tablePropKey))).andReturn(tableProps).once();
+
+    replay(propStore, context);
+
+    ConfigurationCopy defaultConfig =
+        new ConfigurationCopy(Map.of(TABLE_BLOOM_SIZE.getKey(), TABLE_BLOOM_SIZE.getDefaultValue(),
+            TABLE_DURABILITY.getKey(), TABLE_DURABILITY.getDefaultValue()));
+
+    ZooBasedConfiguration sysConfig =
+        new ZooBasedConfiguration(log, context, sysPropKey, defaultConfig);
+    ZooBasedConfiguration nsConfig = new ZooBasedConfiguration(log, context, nsPropKey, sysConfig);
+    ZooBasedConfiguration tableConfig =
+        new ZooBasedConfiguration(log, context, tablePropKey, nsConfig);
+
+    assertNotNull(tableConfig);
+    assertEquals("true", sysConfig.get(TABLE_BLOOM_ENABLED));
+    assertEquals("false", nsConfig.get(TABLE_BLOOM_ENABLED));
+    assertEquals("true", tableConfig.get(TABLE_BLOOM_ENABLED));
+
+    // get from "default" - root parent config
+    assertEquals(TABLE_BLOOM_SIZE.getDefaultValue(), tableConfig.get(TABLE_BLOOM_SIZE));
+    assertEquals(TABLE_DURABILITY.getDefaultValue(), tableConfig.get(TABLE_DURABILITY.getKey()));
+
+    // test getProperties
+    Map<String,String> props = new HashMap<>();
+    Predicate<String> all = x -> true;
+    tableConfig.getProperties(props, all);
+    assertEquals("true", props.get(TABLE_BLOOM_ENABLED.getKey()));
+    assertEquals(TABLE_BLOOM_SIZE.getDefaultValue(), props.get(TABLE_BLOOM_SIZE.getKey()));
+    assertEquals(TABLE_DURABILITY.getDefaultValue(), props.get(TABLE_DURABILITY.getKey()));
+
+    // test filtered getProperties
+    props = new HashMap<>();
+    Predicate<String> blooms = x -> (x.contains("bloom"));
+    tableConfig.getProperties(props, blooms);
+    assertEquals("true", props.get(TABLE_BLOOM_ENABLED.getKey()));
+    // in parent
+    assertEquals(TABLE_BLOOM_SIZE.getDefaultValue(), props.get(TABLE_BLOOM_SIZE.getKey()));
+    // in parent - but excluded by filter
+    assertNull(props.get(TABLE_DURABILITY.getKey()));
+
+    props = new HashMap<>();
+    Predicate<String> noBlooms = x -> (!x.contains("bloom"));
+    tableConfig.getProperties(props, noBlooms);
+    // in table - excluded by filter
+    assertNull(props.get(TABLE_BLOOM_ENABLED.getKey()));
+    // in parent - excluded by filter
+    assertNull(props.get(TABLE_BLOOM_SIZE.getKey()));
+    // in parent - allowed by filter
+    assertEquals(TABLE_DURABILITY.getDefaultValue(), props.get(TABLE_DURABILITY.getKey()));
+  }
+
+  /**
+   * Walk configuration tree sys -> namespace -> table and test update count is calculated correctly
+   * if sysid node is deleted.
+   */
+  @Test
+  public void updateCountTest() {
+    var sysPropKey = SystemPropKey.of(instanceId);
+    VersionedProperties sysProps = new VersionedProperties(100, Instant.now(), Map.of());
+    expect(propStore.get(eq(sysPropKey))).andReturn(sysProps).once();
+    expect(propStore.get(eq(sysPropKey))).andThrow(new IllegalStateException("fake no node"))
+        .anyTimes();
+    // mock node deleted after event
+
+    var nsPropKey = NamespacePropKey.of(instanceId, NamespaceId.of("ns1"));
+    VersionedProperties nsProps = new VersionedProperties(20, Instant.now(), Map.of());
+    expect(propStore.get(eq(nsPropKey))).andReturn(nsProps).once();
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("ns1.table1"));
+    VersionedProperties tableProps = new VersionedProperties(3, Instant.now(), Map.of());
+    expect(propStore.get(eq(tablePropKey))).andReturn(tableProps).once();
+
+    replay(propStore, context);
+
+    ConfigurationCopy defaultConfig = new ConfigurationCopy(Map.of());
+
+    ZooBasedConfiguration sysConfig =
+        new ZooBasedConfiguration(log, context, sysPropKey, defaultConfig);
+    ZooBasedConfiguration nsConfig = new ZooBasedConfiguration(log, context, nsPropKey, sysConfig);
+    ZooBasedConfiguration tableConfig =
+        new ZooBasedConfiguration(log, context, tablePropKey, nsConfig);
+
+    assertNotNull(tableConfig);
+    assertEquals(0, defaultConfig.getUpdateCount());
+    assertEquals(100, sysConfig.getUpdateCount());
+    assertEquals(120, nsConfig.getUpdateCount());
+    assertEquals(123, tableConfig.getUpdateCount());
+
+  }
+
+  /**
+   * Walk configuration tree sys -> namespace -> table and test update count is calculated correctly
+   * if table node is deleted.
+   */
+  @Test
+  public void updateCountTableTest() {
+    var sysPropKey = SystemPropKey.of(instanceId);
+    VersionedProperties sysProps = new VersionedProperties(100, Instant.now(), Map.of());
+    expect(propStore.get(eq(sysPropKey))).andReturn(sysProps).once();
+
+    var nsPropKey = NamespacePropKey.of(instanceId, NamespaceId.of("ns1"));
+    VersionedProperties nsProps = new VersionedProperties(20, Instant.now(), Map.of());
+    expect(propStore.get(eq(nsPropKey))).andReturn(nsProps).once();
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("ns1.table1"));
+    VersionedProperties tableProps = new VersionedProperties(3, Instant.now(), Map.of());
+    expect(propStore.get(eq(tablePropKey))).andReturn(tableProps).once();
+
+    replay(propStore, context);
+
+    ConfigurationCopy defaultConfig = new ConfigurationCopy(Map.of());
+
+    ZooBasedConfiguration sysConfig =
+        new ZooBasedConfiguration(log, context, sysPropKey, defaultConfig);
+    ZooBasedConfiguration nsConfig = new ZooBasedConfiguration(log, context, nsPropKey, sysConfig);
+    ZooBasedConfiguration tableConfig =
+        new ZooBasedConfiguration(log, context, tablePropKey, nsConfig);
+
+    assertNotNull(tableConfig);
+    assertEquals(0, defaultConfig.getUpdateCount());
+    assertEquals(100, sysConfig.getUpdateCount());
+    assertEquals(120, nsConfig.getUpdateCount());
+    assertEquals(123, tableConfig.getUpdateCount());
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/ZooCachePropertyAccessorTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/ZooCachePropertyAccessorTest.java
deleted file mode 100644
index e9ef3d0183..0000000000
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/ZooCachePropertyAccessorTest.java
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-package org.apache.accumulo.server.conf;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertSame;
-
-import java.util.List;
-import java.util.Map;
-import java.util.function.Predicate;
-
-import org.apache.accumulo.core.conf.AccumuloConfiguration;
-import org.apache.accumulo.core.conf.Property;
-import org.apache.accumulo.fate.zookeeper.ZooCache;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-public class ZooCachePropertyAccessorTest {
-  private static final String PATH = "/root/path/to/props";
-  private static final Property PROP = Property.INSTANCE_SECRET;
-  private static final String KEY = PROP.getKey();
-  private static final String FULL_PATH = PATH + "/" + KEY;
-  private static final String VALUE = "value";
-  private static final byte[] VALUE_BYTES = VALUE.getBytes(UTF_8);
-
-  private ZooCache zc;
-  private ZooCachePropertyAccessor a;
-
-  @BeforeEach
-  public void setUp() {
-    zc = createMock(ZooCache.class);
-    a = new ZooCachePropertyAccessor(zc);
-  }
-
-  @Test
-  public void testGetter() {
-    assertSame(zc, a.getZooCache());
-  }
-
-  @Test
-  public void testGet_Valid() {
-    expect(zc.get(FULL_PATH)).andReturn(VALUE_BYTES);
-    replay(zc);
-    assertEquals(VALUE, a.get(PROP, PATH, null));
-  }
-
-  @Test
-  public void testGet_Parent() {
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    expect(parent.get(PROP)).andReturn(VALUE);
-    replay(parent);
-    expect(zc.get(FULL_PATH)).andReturn(null);
-    replay(zc);
-    assertEquals(VALUE, a.get(PROP, PATH, parent));
-  }
-
-  @Test
-  public void testGet_Parent_Null() {
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    expect(parent.get(PROP)).andReturn(null);
-    replay(parent);
-    expect(zc.get(FULL_PATH)).andReturn(null);
-    replay(zc);
-    assertNull(a.get(PROP, PATH, parent));
-  }
-
-  @Test
-  public void testGet_Null_NoParent() {
-    expect(zc.get(FULL_PATH)).andReturn(null);
-    replay(zc);
-    assertNull(a.get(PROP, PATH, null));
-  }
-
-  @Test
-  public void testGet_InvalidFormat() {
-    Property badProp = Property.MANAGER_CLIENTPORT;
-    expect(zc.get(PATH + "/" + badProp.getKey())).andReturn(VALUE_BYTES);
-    replay(zc);
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    expect(parent.get(badProp)).andReturn("12345");
-    replay(parent);
-    assertEquals("12345", a.get(badProp, PATH, parent));
-  }
-
-  @Test
-  public void testGetProperties() {
-    Map<String,String> props = new java.util.HashMap<>();
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    Predicate<String> filter = createMock(Predicate.class);
-    parent.getProperties(props, filter);
-    replay(parent);
-    String child1 = "child1";
-    String child2 = "child2";
-    List<String> children = new java.util.ArrayList<>();
-    children.add(child1);
-    children.add(child2);
-    expect(zc.getChildren(PATH)).andReturn(children);
-    expect(zc.get(PATH + "/" + child1)).andReturn(VALUE_BYTES);
-    expect(zc.get(PATH + "/" + child2)).andReturn(null);
-    replay(zc);
-    expect(filter.test(child1)).andReturn(true);
-    expect(filter.test(child2)).andReturn(true);
-    replay(filter);
-
-    a.getProperties(props, PATH, filter, parent, null);
-    assertEquals(1, props.size());
-    assertEquals(VALUE, props.get(child1));
-    verify(parent);
-  }
-
-  @Test
-  public void testGetProperties_NoChildren() {
-    Map<String,String> props = new java.util.HashMap<>();
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    Predicate<String> filter = createMock(Predicate.class);
-    parent.getProperties(props, filter);
-    replay(parent);
-    expect(zc.getChildren(PATH)).andReturn(null);
-    replay(zc);
-
-    a.getProperties(props, PATH, filter, parent, null);
-    assertEquals(0, props.size());
-  }
-
-  @Test
-  public void testGetProperties_Filter() {
-    Map<String,String> props = new java.util.HashMap<>();
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    Predicate<String> filter = createMock(Predicate.class);
-    parent.getProperties(props, filter);
-    replay(parent);
-    String child1 = "child1";
-    List<String> children = new java.util.ArrayList<>();
-    children.add(child1);
-    expect(zc.getChildren(PATH)).andReturn(children);
-    replay(zc);
-    expect(filter.test(child1)).andReturn(false);
-    replay(filter);
-
-    a.getProperties(props, PATH, filter, parent, null);
-    assertEquals(0, props.size());
-  }
-
-  @Test
-  public void testGetProperties_ParentFilter() {
-    Map<String,String> props = new java.util.HashMap<>();
-    AccumuloConfiguration parent = createMock(AccumuloConfiguration.class);
-    Predicate<String> filter = createMock(Predicate.class);
-    Predicate<String> parentFilter = createMock(Predicate.class);
-    parent.getProperties(props, parentFilter);
-    replay(parent);
-    expect(zc.getChildren(PATH)).andReturn(null);
-    replay(zc);
-
-    a.getProperties(props, PATH, filter, parent, parentFilter);
-    verify(parent);
-  }
-
-  @Test
-  public void testInvalidateCache() {
-    zc.clear();
-    replay(zc);
-    a.invalidateCache();
-    verify(zc);
-  }
-}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropCodecTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropCodecTest.java
index c8cac363c0..19ca358e73 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropCodecTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropCodecTest.java
@@ -53,18 +53,18 @@ public class VersionedPropCodecTest {
   public void getDataVersionBadTimestamp() {
     // length so that array reads do not error
     byte[] bytes = new byte[100];
-    assertThrows(IllegalArgumentException.class, () -> VersionedPropCodec.getDataVersion(bytes));
+    assertThrows(IllegalArgumentException.class, () -> VersionedPropCodec.readTimestamp(bytes));
   }
 
   @Test
   public void goPath() throws IOException {
     int aVersion = 13;
-    VersionedProperties vProps =
-        new VersionedProperties(aVersion, Instant.now(), Map.of("k1", "v1"));
+    var timestamp = Instant.now();
+    VersionedProperties vProps = new VersionedProperties(aVersion, timestamp, Map.of("k1", "v1"));
 
     VersionedPropCodec codec = VersionedPropGzipCodec.codec(true);
     byte[] encodedBytes = codec.toBytes(vProps);
 
-    assertEquals(aVersion + 1, VersionedPropCodec.getDataVersion(encodedBytes));
+    assertEquals(timestamp, VersionedPropCodec.readTimestamp(encodedBytes));
   }
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodec.java b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodec.java
index cb95b822f0..9448fc0b36 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodec.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodec.java
@@ -109,7 +109,7 @@ public class VersionedPropEncryptCodec extends VersionedPropCodec {
 
     }
 
-    Map<String,String> props = vProps.getProperties();
+    Map<String,String> props = vProps.asMap();
 
     // encode the property map to an internal byte array.
     byte[] bytes;
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodecTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodecTest.java
index 7019ac7c6d..8fdb2e0641 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodecTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropEncryptCodecTest.java
@@ -112,11 +112,12 @@ public class VersionedPropEncryptCodecTest {
 
     log.debug("Encoded: {}", encodedBytes);
 
-    VersionedProperties decodedProps = encoder.fromBytes(encodedBytes);
+    // store would increment version on write
+    VersionedProperties decodedProps = encoder.fromBytes(aVersion + 1, encodedBytes);
 
     log.debug("Decoded: {}", decodedProps.print(true));
 
-    assertEquals(vProps.getProperties(), decodedProps.getProperties());
+    assertEquals(vProps.asMap(), decodedProps.asMap());
 
     // validate that the expected node version matches original version.
     assertEquals(aVersion, vProps.getDataVersion());
@@ -125,10 +126,6 @@ public class VersionedPropEncryptCodecTest {
     assertEquals(aVersion + 1, decodedProps.getDataVersion());
 
     assertEquals(aVersion + 1, decodedProps.getDataVersion(), "encoded version should be 1 up");
-    assertEquals(vProps.getNextVersion(), decodedProps.getDataVersion(),
-        "version written should be the source next version");
-    assertEquals(aVersion + 2, decodedProps.getNextVersion(),
-        "the next version in decoded should be +2");
 
     assertTrue(vProps.getTimestamp().compareTo(Instant.now()) <= 0,
         "timestamp should be now or earlier");
@@ -163,11 +160,12 @@ public class VersionedPropEncryptCodecTest {
 
     log.debug("len: {}, bytes: {}", encodedBytes.length, encodedBytes);
 
-    VersionedProperties decodedProps = encoder1.fromBytes(encodedBytes);
+    // store would increment version on write
+    VersionedProperties decodedProps = encoder1.fromBytes(aVersion + 1, encodedBytes);
 
     log.debug("Decoded: {}", decodedProps.print(true));
 
-    assertEquals(vProps.getProperties(), decodedProps.getProperties());
+    assertEquals(vProps.asMap(), decodedProps.asMap());
 
     // validate that the expected node version matches original version.
     assertEquals(aVersion, vProps.getDataVersion());
@@ -176,10 +174,6 @@ public class VersionedPropEncryptCodecTest {
     assertEquals(aVersion + 1, decodedProps.getDataVersion());
 
     assertEquals(aVersion + 1, decodedProps.getDataVersion(), "encoded version should be 1 up");
-    assertEquals(vProps.getNextVersion(), decodedProps.getDataVersion(),
-        "version written should be the source next version");
-    assertEquals(aVersion + 2, decodedProps.getNextVersion(),
-        "the next version in decoded should be +2");
 
     assertTrue(vProps.getTimestamp().compareTo(Instant.now()) <= 0,
         "timestamp should be now or earlier");
@@ -210,17 +204,17 @@ public class VersionedPropEncryptCodecTest {
     log.debug("Encoded: {}", encodedBytes1);
     log.debug("Encoded: {}", encodedBytes2);
 
-    VersionedProperties from2 = codec1.fromBytes(encodedBytes2);
-    VersionedProperties from1 = codec2.fromBytes(encodedBytes1);
+    VersionedProperties from2 = codec1.fromBytes(0, encodedBytes2);
+    VersionedProperties from1 = codec2.fromBytes(0, encodedBytes1);
 
-    assertEquals(from1.getProperties(), from2.getProperties());
+    assertEquals(from1.asMap(), from2.asMap());
 
     VersionedPropCodec codec3 = VersionedPropEncryptCodec.codec(false,
         new VersionedPropEncryptCodec.GCMCipherParams(pass, salt));
 
-    VersionedProperties from3 = codec3.fromBytes(encodedBytes1);
+    VersionedProperties from3 = codec3.fromBytes(0, encodedBytes1);
     assertEquals(from1.getDataVersion(), from3.getDataVersion());
-    assertEquals(from1.getProperties(), from3.getProperties());
+    assertEquals(from1.asMap(), from3.asMap());
 
     assertNotEquals(encodedBytes1, encodedBytes2);
 
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodecTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodecTest.java
index 8a9663db8f..d4089b4cc9 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodecTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropGzipCodecTest.java
@@ -44,17 +44,15 @@ public class VersionedPropGzipCodecTest {
 
     byte[] encodedMapBytes = encoder.toBytes(vProps);
 
-    VersionedProperties decodedProps = encoder.fromBytes(encodedMapBytes);
+    VersionedProperties decodedProps = encoder.fromBytes(0, encodedMapBytes);
 
-    log.debug("Decoded: {}", decodedProps.getProperties());
+    log.debug("Decoded: {}", decodedProps.asMap());
 
     // default - first write version should be 0
     assertEquals(0, decodedProps.getDataVersion(), "default - first write version should be 0");
-    assertEquals(1, decodedProps.getNextVersion(),
-        "default - first write next version should be 1");
     assertTrue(vProps.getTimestamp().compareTo(Instant.now()) <= 0,
         "timestamp should be now or earlier");
-    assertEquals(vProps.getProperties(), decodedProps.getProperties());
+    assertEquals(vProps.asMap(), decodedProps.asMap());
   }
 
   @Test
@@ -65,16 +63,14 @@ public class VersionedPropGzipCodecTest {
 
     byte[] encodedMapBytes = codec.toBytes(vProps);
 
-    VersionedProperties decodedProps = codec.fromBytes(encodedMapBytes);
+    VersionedProperties decodedProps = codec.fromBytes(0, encodedMapBytes);
 
-    log.debug("Decoded: {}", decodedProps.getProperties());
+    log.debug("Decoded: {}", decodedProps.asMap());
 
     assertEquals(0, decodedProps.getDataVersion(), "default - first write version should be 0");
-    assertEquals(1, decodedProps.getNextVersion(),
-        "default - first write next version should be 1");
     assertTrue(vProps.getTimestamp().compareTo(Instant.now()) <= 0,
         "timestamp should be now or earlier");
-    assertEquals(vProps.getProperties(), decodedProps.getProperties());
+    assertEquals(vProps.asMap(), decodedProps.asMap());
   }
 
   /**
@@ -90,11 +86,11 @@ public class VersionedPropGzipCodecTest {
     VersionedPropCodec codec = VersionedPropGzipCodec.codec(true);
     byte[] encodedBytes = codec.toBytes(vProps);
 
-    VersionedProperties decodedProps = codec.fromBytes(encodedBytes);
+    VersionedProperties decodedProps = codec.fromBytes(aVersion + 1, encodedBytes);
 
     log.trace("Decoded: {}", decodedProps.print(true));
 
-    assertEquals(vProps.getProperties(), decodedProps.getProperties());
+    assertEquals(vProps.asMap(), decodedProps.asMap());
 
     // validate that the expected node version matches original version.
     assertEquals(aVersion, vProps.getDataVersion());
@@ -103,10 +99,6 @@ public class VersionedPropGzipCodecTest {
     assertEquals(aVersion + 1, decodedProps.getDataVersion());
 
     assertEquals(aVersion + 1, decodedProps.getDataVersion(), "encoded version should be 1 up");
-    assertEquals(vProps.getNextVersion(), decodedProps.getDataVersion(),
-        "version written should be the source next version");
-    assertEquals(aVersion + 2, decodedProps.getNextVersion(),
-        "the next version in decoded should be +2");
 
     assertTrue(vProps.getTimestamp().compareTo(Instant.now()) <= 0,
         "timestamp should be now or earlier");
@@ -122,11 +114,11 @@ public class VersionedPropGzipCodecTest {
     VersionedPropCodec codec = VersionedPropGzipCodec.codec(true);
     byte[] encodedBytes = codec.toBytes(vProps);
 
-    VersionedProperties decodedProps = codec.fromBytes(encodedBytes);
+    VersionedProperties decodedProps = codec.fromBytes(0, encodedBytes);
 
     log.debug("Decoded: {}", decodedProps.print(true));
 
-    assertEquals(vProps.getProperties(), decodedProps.getProperties());
+    assertEquals(vProps.asMap(), decodedProps.asMap());
 
   }
 }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropertiesTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropertiesTest.java
index 739616c7e2..e3e52eec96 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropertiesTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/codec/VersionedPropertiesTest.java
@@ -47,7 +47,7 @@ public class VersionedPropertiesTest {
     initProps.put("key3", "value3");
     VersionedProperties vProps = new VersionedProperties(initProps);
 
-    Map<String,String> propMap = vProps.getProperties();
+    Map<String,String> propMap = vProps.asMap();
 
     assertEquals(initProps.size(), propMap.size());
 
@@ -65,9 +65,9 @@ public class VersionedPropertiesTest {
     VersionedProperties vProps = new VersionedProperties();
 
     assertNotNull(vProps);
-    assertEquals(0, vProps.getProperties().size());
-    assertNull(vProps.getProperties().get("key1"));
-    assertEquals(Collections.emptyMap(), vProps.getProperties());
+    assertEquals(0, vProps.asMap().size());
+    assertNull(vProps.asMap().get("key1"));
+    assertEquals(Collections.emptyMap(), vProps.asMap());
   }
 
   @Test
@@ -85,7 +85,7 @@ public class VersionedPropertiesTest {
 
     VersionedProperties vProps = new VersionedProperties(aMap);
 
-    Map<String,String> rMap = vProps.getProperties();
+    Map<String,String> rMap = vProps.asMap();
     assertEquals(aMap.size(), rMap.size());
 
     assertThrows(UnsupportedOperationException.class, () -> rMap.put("k3", "v3"));
@@ -98,12 +98,12 @@ public class VersionedPropertiesTest {
     VersionedProperties vProps = new VersionedProperties();
     vProps = vProps.addOrUpdate("k1", "v1");
 
-    assertEquals("v1", vProps.getProperties().get("k1"));
-    assertEquals(1, vProps.getProperties().size());
+    assertEquals("v1", vProps.asMap().get("k1"));
+    assertEquals(1, vProps.asMap().size());
 
     vProps = vProps.addOrUpdate("k1", "v1-2");
 
-    assertEquals("v1-2", vProps.getProperties().get("k1"));
+    assertEquals("v1-2", vProps.asMap().get("k1"));
   }
 
   @Test
@@ -115,8 +115,8 @@ public class VersionedPropertiesTest {
 
     VersionedProperties vProps = new VersionedProperties(aMap);
 
-    assertEquals("v1", vProps.getProperties().get("k1"));
-    assertEquals(aMap.size(), vProps.getProperties().size());
+    assertEquals("v1", vProps.asMap().get("k1"));
+    assertEquals(aMap.size(), vProps.asMap().size());
 
     Map<String,String> bMap = new HashMap<>();
     bMap.put("k1", "v1-1");
@@ -124,10 +124,10 @@ public class VersionedPropertiesTest {
 
     VersionedProperties updated = vProps.addOrUpdate(bMap);
 
-    assertEquals(2, vProps.getProperties().size());
-    assertEquals(3, updated.getProperties().size());
+    assertEquals(2, vProps.asMap().size());
+    assertEquals(3, updated.asMap().size());
 
-    assertEquals("v1-1", updated.getProperties().get("k1"));
+    assertEquals("v1-1", updated.asMap().get("k1"));
 
   }
 
@@ -140,24 +140,21 @@ public class VersionedPropertiesTest {
 
     VersionedProperties vProps = new VersionedProperties(aMap);
 
-    assertEquals("v1", vProps.getProperties().get("k1"));
-    assertEquals(aMap.size(), vProps.getProperties().size());
+    assertEquals("v1", vProps.asMap().get("k1"));
+    assertEquals(aMap.size(), vProps.asMap().size());
 
     // remove 1 existing and 1 not present
     VersionedProperties vProps2 = vProps.remove(Arrays.asList("k1", "k3"));
 
-    assertEquals(1, vProps2.getProperties().size());
-    assertNull(vProps2.getProperties().get("k1"));
-    assertEquals("v2", vProps2.getProperties().get("k2"));
+    assertEquals(1, vProps2.asMap().size());
+    assertNull(vProps2.asMap().get("k1"));
+    assertEquals("v2", vProps2.asMap().get("k2"));
   }
 
   @Test
   public void getInitialDataVersion() {
     VersionedProperties vProps = new VersionedProperties();
     assertEquals(0, vProps.getDataVersion());
-
-    // the initial version for write should be 0
-    assertEquals(0, vProps.getNextVersion(), "Initial expected version should be 0");
     assertTrue(vProps.getTimestamp().compareTo(Instant.now()) <= 0,
         "timestamp should be now or earlier");
   }
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/store/PropCacheKeyTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/store/PropCacheKeyTest.java
new file mode 100644
index 0000000000..39982dc5fb
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/store/PropCacheKeyTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store;
+
+import static org.apache.accumulo.core.Constants.ZCONFIG;
+import static org.apache.accumulo.core.Constants.ZNAMESPACES;
+import static org.apache.accumulo.core.Constants.ZTABLES;
+import static org.apache.accumulo.server.conf.store.PropCacheKey.PROP_NODE_NAME;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.UUID;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.server.ServerContext;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PropCacheKeyTest {
+  private static final Logger log = LoggerFactory.getLogger(PropCacheKeyTest.class);
+
+  private final InstanceId instanceId = InstanceId.of(UUID.randomUUID());
+
+  @Test
+  public void systemType() {
+    var propKey = SystemPropKey.of(instanceId);
+    log.info("name: {}", propKey);
+    assertTrue(propKey.getPath().endsWith(ZCONFIG + "/" + PROP_NODE_NAME));
+  }
+
+  @Test
+  public void systemTypeFromContext() {
+    ServerContext context = createMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(instanceId).once();
+    replay(context);
+
+    var propKey = SystemPropKey.of(context);
+    log.info("propKey: {}", propKey);
+    assertTrue(propKey.getPath().endsWith(ZCONFIG + "/" + PROP_NODE_NAME));
+    verify(context);
+  }
+
+  @Test
+  public void namespaceType() {
+    var propKey = NamespacePropKey.of(instanceId, NamespaceId.of("a"));
+    log.info("propKey: {}", propKey);
+    assertTrue(
+        propKey.getPath().endsWith(PROP_NODE_NAME) && propKey.getPath().contains(ZNAMESPACES));
+    log.info("propKey: {}", propKey);
+  }
+
+  @Test
+  public void namespaceTypeFromContext() {
+    ServerContext context = createMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(instanceId).once();
+    replay(context);
+
+    var propKey = NamespacePropKey.of(context, NamespaceId.of("a"));
+    assertTrue(
+        propKey.getPath().endsWith(PROP_NODE_NAME) && propKey.getPath().contains(ZNAMESPACES));
+    verify(context);
+  }
+
+  @Test
+  public void tableType() {
+    var propKey = TablePropKey.of(instanceId, TableId.of("a"));
+    log.info("propKey: {}", propKey);
+    assertTrue(propKey.getPath().endsWith(PROP_NODE_NAME) && propKey.getPath().contains(ZTABLES));
+    log.info("propKey: {}", propKey);
+  }
+
+  @Test
+  public void fromPathTest() {
+
+    var iid = "3f9976c6-3bf1-41ab-9751-1b0a9be3551d";
+
+    PropCacheKey<?> t1 =
+        PropCacheKey.fromPath("/accumulo/" + iid + "/tables/t1/conf/encoded_props");
+    assertNotNull(t1);
+    assertEquals(TableId.of("t1"), t1.getId());
+
+    PropCacheKey<?> n1 =
+        PropCacheKey.fromPath("/accumulo/" + iid + "/namespaces/n1/conf/encoded_props");
+    assertNotNull(n1);
+    assertEquals(NamespaceId.of("n1"), n1.getId());
+    assertNotNull(n1.getId());
+
+    PropCacheKey<?> s1 = PropCacheKey.fromPath("/accumulo/" + iid + "/config/encoded_props");
+    assertNotNull(s1);
+    // system config returns instance id as id placeholder
+    assertEquals(iid, s1.getId().canonical());
+  }
+
+  @Test
+  public void getBasePathTest() {
+    assertTrue(SystemPropKey.of(instanceId).getBasePath().endsWith("/config"));
+    assertTrue(
+        NamespacePropKey.of(instanceId, NamespaceId.of("123")).getBasePath().endsWith("/conf"));
+    assertTrue(TablePropKey.of(instanceId, TableId.of("456")).getBasePath().endsWith("/conf"));
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/PropCacheCaffeineImplTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/PropCacheCaffeineImplTest.java
new file mode 100644
index 0000000000..d6a766e7d3
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/PropCacheCaffeineImplTest.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static org.apache.accumulo.core.conf.Property.TABLE_BULK_MAX_TABLETS;
+import static org.apache.accumulo.core.conf.Property.TABLE_FILE_BLOCK_SIZE;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.core.metrics.MetricsUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.github.benmanes.caffeine.cache.Ticker;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+public class PropCacheCaffeineImplTest {
+
+  private InstanceId instanceId;
+  private ServerContext context;
+  private PropStoreWatcher propStoreWatcher;
+  private ZooPropLoader zooPropLoader;
+  private TestTicker ticker;
+
+  private TablePropKey tablePropKey;
+  private VersionedProperties vProps;
+
+  private PropCacheCaffeineImpl cache = null;
+
+  @BeforeEach
+  @SuppressFBWarnings(value = "PREDICTABLE_RANDOM",
+      justification = "random used for testing with variable names")
+  public void init() {
+    ticker = new TestTicker();
+    instanceId = InstanceId.of(UUID.randomUUID());
+
+    PropStoreMetrics cacheMetrics = new PropStoreMetrics();
+    MetricsUtil.initializeProducers(cacheMetrics);
+
+    tablePropKey =
+        TablePropKey.of(instanceId, TableId.of("t" + ThreadLocalRandom.current().nextInt(1, 1000)));
+
+    Map<String,String> props =
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M");
+
+    vProps = new VersionedProperties(props);
+
+    // mocks
+    context = createMock(ServerContext.class);
+    propStoreWatcher = createMock(PropStoreWatcher.class);
+    zooPropLoader = createMock(ZooPropLoader.class);
+
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+
+    cache =
+        new PropCacheCaffeineImpl.Builder(zooPropLoader, cacheMetrics).withTicker(ticker).build();
+
+  }
+
+  @AfterEach
+  public void verifyMocks() {
+    verify(context, propStoreWatcher, zooPropLoader);
+  }
+
+  @Test
+  public void getTest() {
+    expect(zooPropLoader.load(eq(tablePropKey))).andReturn(vProps).once();
+    replay(context, propStoreWatcher, zooPropLoader);
+    assertNotNull(cache.get(tablePropKey)); // will call load
+    assertNotNull(cache.get(tablePropKey)); // will read from cache
+  }
+
+  /**
+   * Verifies that value is not cached
+   */
+  @SuppressFBWarnings(value = "PREDICTABLE_RANDOM",
+      justification = "random used for testing with variable names")
+  @Test
+  public void getNoCacheTest() {
+    var table2PropKey = TablePropKey.of(instanceId,
+        TableId.of("t2" + ThreadLocalRandom.current().nextInt(1, 1000)));
+
+    expect(zooPropLoader.load(eq(table2PropKey))).andReturn(vProps).once();
+
+    replay(context, propStoreWatcher, zooPropLoader);
+    var props = cache.getWithoutCaching(tablePropKey);
+    assertNull(props);
+
+    assertNotNull(cache.get(table2PropKey)); // load into cache
+    assertNotNull(cache.getWithoutCaching(table2PropKey)); // read from cache - no load call.
+  }
+
+  @Test
+  public void removeTest() {
+    expect(zooPropLoader.load(eq(tablePropKey))).andReturn(vProps).times(2);
+    replay(context, propStoreWatcher, zooPropLoader);
+    assertNotNull(cache.get(tablePropKey)); // will call load
+    cache.remove(tablePropKey);
+    assertNotNull(cache.get(tablePropKey)); // will call load again
+  }
+
+  @SuppressFBWarnings(value = "PREDICTABLE_RANDOM",
+      justification = "random used for testing with variable names")
+  @Test
+  public void removeAllTest() {
+    var table2PropKey = TablePropKey.of(instanceId,
+        TableId.of("t2" + ThreadLocalRandom.current().nextInt(1, 1000)));
+
+    expect(zooPropLoader.load(eq(tablePropKey))).andReturn(vProps).once();
+    expect(zooPropLoader.load(eq(table2PropKey))).andReturn(vProps).once();
+
+    replay(context, propStoreWatcher, zooPropLoader);
+
+    assertNotNull(cache.get(tablePropKey)); // will call load
+    assertNotNull(cache.get(table2PropKey)); // will call load
+
+    cache.removeAll();
+
+    // check that values are not in cache - will not call load
+    assertNull(cache.getWithoutCaching(tablePropKey));
+    assertNull(cache.getWithoutCaching(table2PropKey));
+  }
+
+  VersionedProperties asyncProps() {
+    return vProps;
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test
+  public void refreshTest() throws Exception {
+
+    expect(zooPropLoader.load(eq(tablePropKey))).andReturn(vProps).once();
+
+    CompletableFuture future = CompletableFuture.supplyAsync(this::asyncProps);
+
+    expect(zooPropLoader.asyncReload(eq(tablePropKey), eq(vProps), anyObject())).andReturn(future)
+        .once();
+
+    replay(context, propStoreWatcher, zooPropLoader);
+    assertNotNull(cache.get(tablePropKey)); // will call load and place into cache
+
+    ticker.advance(30, TimeUnit.MINUTES);
+    cache.cleanUp();
+    assertNotNull(cache.get(tablePropKey)); // will async check stat and then reload
+  }
+
+  @SuppressWarnings({"rawtypes"})
+  @Test
+  public void expireTest() {
+    expect(zooPropLoader.load(eq(tablePropKey))).andReturn(vProps).times(2);
+
+    replay(context, propStoreWatcher, zooPropLoader);
+    assertNotNull(cache.get(tablePropKey)); // will call load
+
+    ticker.advance(90, TimeUnit.MINUTES);
+    cache.cleanUp();
+    assertNotNull(cache.get(tablePropKey)); // expired - will call load.
+  }
+
+  @Test
+  public void getExceptionTest() {
+    expect(zooPropLoader.load(eq(tablePropKey)))
+        .andThrow(new IllegalStateException("forced test exception")).once();
+    replay(context, propStoreWatcher, zooPropLoader);
+    assertNull(cache.get(tablePropKey)); // will call load
+  }
+
+  public static class TestTicker implements Ticker {
+
+    private final long startTime;
+    private long elapsed;
+
+    public TestTicker() {
+      startTime = System.nanoTime();
+      elapsed = 0L;
+    }
+
+    public void advance(final long value, final TimeUnit units) {
+      elapsed += TimeUnit.NANOSECONDS.convert(value, units);
+    }
+
+    @Override
+    public long read() {
+      return startTime + elapsed;
+    }
+  }
+
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/PropStoreEventTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/PropStoreEventTest.java
new file mode 100644
index 0000000000..59d28c3c77
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/PropStoreEventTest.java
@@ -0,0 +1,318 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static org.apache.accumulo.core.conf.Property.TABLE_BULK_MAX_TABLETS;
+import static org.apache.accumulo.core.conf.Property.TABLE_FILE_BLOCK_SIZE;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.capture;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.newCapture;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.core.metrics.MetricsUtil;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.PropChangeListener;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.zookeeper.WatchedEvent;
+import org.apache.zookeeper.Watcher;
+import org.apache.zookeeper.data.Stat;
+import org.easymock.Capture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class PropStoreEventTest {
+  private final VersionedPropCodec propCodec = VersionedPropCodec.getDefault();
+  private InstanceId instanceId;
+
+  // mocks
+  private ServerContext context;
+  private ZooReaderWriter zrw;
+  private ReadyMonitor readyMonitor;
+
+  @BeforeEach
+  public void initCommonMocks() throws Exception {
+    instanceId = InstanceId.of(UUID.randomUUID());
+    context = createMock(ServerContext.class);
+    zrw = createMock(ZooReaderWriter.class);
+    expect(context.getZooReaderWriter()).andReturn(zrw).anyTimes();
+    expect(context.getZooKeepersSessionTimeOut()).andReturn(500).anyTimes();
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+
+    expect(zrw.exists(eq("/accumulo/" + instanceId), anyObject())).andReturn(true).anyTimes();
+
+    readyMonitor = createMock(ReadyMonitor.class);
+  }
+
+  @AfterEach
+  public void verifyMocks() {
+    verify(context, zrw, readyMonitor);
+  }
+
+  @Test
+  public void zkChangeEventTest() throws Exception {
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("a1"));
+
+    PropStoreWatcher watcher = new PropStoreWatcher(readyMonitor);
+
+    WatchedEvent zkEvent = createMock(WatchedEvent.class);
+    expect(zkEvent.getPath()).andReturn(tablePropKey.getPath()).once();
+    expect(zkEvent.getType()).andReturn(Watcher.Event.EventType.NodeDataChanged);
+    readyMonitor.setReady();
+    expectLastCall().once();
+
+    replay(context, zrw, readyMonitor, zkEvent);
+
+    PropStore propStore = new ZooPropStore(instanceId, zrw, readyMonitor, watcher, null);
+    StoreTestListener listener = new StoreTestListener();
+
+    propStore.registerAsListener(tablePropKey, listener);
+
+    watcher.process(zkEvent);
+
+    Thread.sleep(150);
+
+    assertEquals(1, listener.getZkChangeEventCount());
+
+    verify(zkEvent);
+  }
+
+  @Test
+  public void deleteEventTest() throws Exception {
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("a1"));
+
+    PropStoreWatcher watcher = new PropStoreWatcher(readyMonitor);
+
+    WatchedEvent zkEvent = createMock(WatchedEvent.class);
+    expect(zkEvent.getPath()).andReturn(tablePropKey.getPath()).once();
+    expect(zkEvent.getType()).andReturn(Watcher.Event.EventType.NodeDeleted);
+
+    readyMonitor.setReady();
+    expectLastCall().once();
+
+    replay(context, zrw, readyMonitor, zkEvent);
+
+    PropStore propStore = new ZooPropStore(instanceId, zrw, readyMonitor, watcher, null);
+
+    StoreTestListener listener = new StoreTestListener();
+
+    propStore.registerAsListener(tablePropKey, listener);
+
+    watcher.process(zkEvent);
+
+    Thread.sleep(150);
+    assertEquals(1, listener.getDeleteEventCount());
+    verify(zkEvent);
+
+  }
+
+  @Test
+  public void disconnectEventTest() throws Exception {
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("a1"));
+
+    PropStoreWatcher watcher = new PropStoreWatcher(readyMonitor);
+
+    WatchedEvent zkEvent = createMock(WatchedEvent.class);
+    expect(zkEvent.getType()).andReturn(Watcher.Event.EventType.None);
+    expect(zkEvent.getState()).andReturn(Watcher.Event.KeeperState.Disconnected).once();
+
+    readyMonitor.setReady();
+    expectLastCall().once();
+    readyMonitor.clearReady();
+    expectLastCall();
+
+    replay(context, zrw, readyMonitor, zkEvent);
+
+    PropStore propStore = new ZooPropStore(instanceId, zrw, readyMonitor, watcher, null);
+
+    StoreTestListener listener = new StoreTestListener();
+
+    propStore.registerAsListener(tablePropKey, listener);
+
+    watcher.process(zkEvent);
+
+    Thread.sleep(150);
+    assertEquals(1, listener.getConnectionEventCount());
+    verify(zkEvent);
+
+  }
+
+  @Test
+  public void closedEventTest() throws Exception {
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("a1"));
+
+    PropStoreWatcher watcher = new PropStoreWatcher(readyMonitor);
+
+    WatchedEvent zkEvent = createMock(WatchedEvent.class);
+    expect(zkEvent.getType()).andReturn(Watcher.Event.EventType.None);
+    expect(zkEvent.getState()).andReturn(Watcher.Event.KeeperState.Closed).once();
+
+    readyMonitor.setReady();
+    expectLastCall();
+    readyMonitor.clearReady();
+    expectLastCall();
+    readyMonitor.setClosed();
+    expectLastCall();
+
+    replay(context, zrw, readyMonitor, zkEvent);
+
+    PropStore propStore = new ZooPropStore(instanceId, zrw, readyMonitor, watcher, null);
+
+    StoreTestListener listener = new StoreTestListener();
+
+    propStore.registerAsListener(tablePropKey, listener);
+
+    watcher.process(zkEvent);
+
+    Thread.sleep(150);
+    assertEquals(1, listener.getConnectionEventCount());
+    verify(zkEvent);
+  }
+
+  @Test
+  public void cacheChangeEventTest() throws Exception {
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("a1"));
+
+    PropStoreWatcher watcher = new PropStoreWatcher(readyMonitor);
+    readyMonitor.setReady();
+    expectLastCall().once();
+
+    replay(context, zrw, readyMonitor);
+
+    PropStore propStore = new ZooPropStore(instanceId, zrw, readyMonitor, watcher, null);
+
+    StoreTestListener listener = new StoreTestListener();
+
+    propStore.registerAsListener(tablePropKey, listener);
+
+    watcher.signalCacheChangeEvent(tablePropKey);
+
+    Thread.sleep(150);
+    assertEquals(1, listener.getCacheChangeEventCount());
+  }
+
+  @Test
+  public void validateWatcherSetTest() throws Exception {
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("a1"));
+
+    Map<String,String> props1 =
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M");
+
+    VersionedProperties vProps = new VersionedProperties(11, Instant.now(), props1);
+    Capture<Stat> stat = newCapture();
+
+    // first call loads cache
+    expect(zrw.getData(eq(tablePropKey.getPath()), anyObject(), capture(stat))).andAnswer(() -> {
+      Stat s = stat.getValue();
+      s.setCtime(System.currentTimeMillis());
+      s.setMtime(System.currentTimeMillis());
+      s.setVersion(12);
+      stat.setValue(s);
+      return propCodec.toBytes(vProps);
+    }).once();
+
+    PropStoreWatcher watcher = new PropStoreWatcher(readyMonitor);
+
+    replay(context, zrw, readyMonitor);
+
+    PropStoreMetrics metrics = new PropStoreMetrics();
+    MetricsUtil.initializeProducers(metrics);
+
+    ZooPropLoader loader = new ZooPropLoader(zrw, propCodec, watcher, metrics);
+
+    PropCacheCaffeineImpl cache = new PropCacheCaffeineImpl.Builder(loader, metrics).build();
+
+    // load cache
+    var read1 = cache.get(tablePropKey);
+    assertNotNull(read1);
+    assertEquals("1234", read1.asMap().get(TABLE_BULK_MAX_TABLETS.getKey()));
+    assertEquals("512M", read1.asMap().get(TABLE_FILE_BLOCK_SIZE.getKey()));
+
+    watcher.process(
+        new WatchedEvent(Watcher.Event.EventType.NodeDataChanged, null, tablePropKey.getPath()));
+
+  }
+
+  private static class StoreTestListener implements PropChangeListener {
+
+    private int zkChangeEventCount = 0;
+    private int cacheChangeEventCount = 0;
+    private int deleteEventCount = 0;
+    private int connectionEventCount = 0;
+
+    @Override
+    public void zkChangeEvent(PropCacheKey<?> propCacheKey) {
+      zkChangeEventCount++;
+    }
+
+    @Override
+    public void cacheChangeEvent(PropCacheKey<?> propCacheKey) {
+      cacheChangeEventCount++;
+    }
+
+    @Override
+    public void deleteEvent(PropCacheKey<?> propCacheKey) {
+      deleteEventCount++;
+    }
+
+    @Override
+    public void connectionEvent() {
+      connectionEventCount++;
+    }
+
+    public int getZkChangeEventCount() {
+      return zkChangeEventCount;
+    }
+
+    public int getCacheChangeEventCount() {
+      return cacheChangeEventCount;
+    }
+
+    public int getDeleteEventCount() {
+      return deleteEventCount;
+    }
+
+    public int getConnectionEventCount() {
+      return connectionEventCount;
+    }
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ReadyMonitorTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ReadyMonitorTest.java
new file mode 100644
index 0000000000..e6d941121b
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ReadyMonitorTest.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.apache.accumulo.core.util.threads.ThreadPools;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReadyMonitorTest {
+
+  private static final Logger log = LoggerFactory.getLogger(ReadyMonitorTest.class);
+
+  private final int numWorkerThreads = 16;
+  private CountDownLatch readyToRunLatch = null;
+  private CountDownLatch completedLatch = null;
+
+  private ThreadPoolExecutor workerPool = null;
+
+  @BeforeEach
+  public void init() {
+
+    readyToRunLatch = new CountDownLatch(numWorkerThreads);
+    completedLatch = new CountDownLatch(numWorkerThreads);
+
+    // these tests wait for workers to signal ready using count down latch.
+    // size pool so some threads are likely to wait on others to complete.
+    int numPoolThreads = numWorkerThreads / 2;
+    workerPool = ThreadPools.getServerThreadPools().createFixedThreadPool(numPoolThreads,
+        "readyMonitor-test-pool", false);
+  }
+
+  @AfterEach
+  public void teardown() {
+    workerPool.shutdownNow();
+    try {
+      boolean terminated = workerPool.awaitTermination(2000, MILLISECONDS);
+      log.trace("Worked pool successfully terminated: {}", terminated);
+    } catch (InterruptedException ex) {
+      // don't care.
+      workerPool.shutdownNow();
+    }
+  }
+
+  @Test
+  public void isReadyST() {
+    ReadyMonitor readyMonitor = new ReadyMonitor("test", SECONDS.toMillis(1));
+    assertFalse(readyMonitor.test());
+
+    readyMonitor.setReady();
+    assertTrue(readyMonitor.test());
+
+    readyMonitor.isReady();
+  }
+
+  @Test
+  public void clearTest() {
+
+    ReadyMonitor readyMonitor = new ReadyMonitor("test", SECONDS.toMillis(1));
+    assertFalse(readyMonitor.test());
+
+    readyMonitor.setReady();
+    assertTrue(readyMonitor.test());
+
+    readyMonitor.clearReady();
+    assertFalse(readyMonitor.test());
+
+    assertThrows(IllegalStateException.class, readyMonitor::isReady);
+  }
+
+  @Test
+  public void notReady() {
+    ReadyMonitor readyMonitor = new ReadyMonitor("test", SECONDS.toMillis(1));
+    assertFalse(readyMonitor.test());
+
+    assertThrows(IllegalStateException.class, readyMonitor::isReady);
+  }
+
+  @Test
+  public void isReadyMultiThread() throws Exception {
+    final long readyTestTimeout = SECONDS.toMillis(20);
+    ReadyMonitor readyMonitor = new ReadyMonitor("test", readyTestTimeout);
+    assertFalse(readyMonitor.test());
+
+    log.debug("start latch - {}", readyToRunLatch.getCount());
+
+    List<Future<Long>> tasks = new ArrayList<>();
+    for (int i = 0; i < numWorkerThreads; i++) {
+      ReadyTask r = new ReadyTask(readyMonitor, readyToRunLatch, completedLatch);
+      tasks.add(workerPool.submit(r));
+    }
+
+    Thread.sleep(1_000);
+
+    var allReady = readyToRunLatch.await(10_000, MILLISECONDS);
+    var readyCount = readyToRunLatch.getCount();
+    log.trace("All ready: {}, have {}", allReady, readyCount);
+
+    assertTrue(readyCount > 1,
+        "failed - all worker tasks did not report ready - have: " + readyCount);
+
+    readyMonitor.setReady();
+
+    var allComplete = completedLatch.await(20_000, MILLISECONDS);
+    assertTrue(allComplete,
+        "failed - all expected tasks did not complete - count=" + completedLatch.getCount());
+
+    tasks.forEach(f -> {
+      try {
+        var timeWaiting = f.get();
+        log.debug("waiting: {}", NANOSECONDS.toSeconds(timeWaiting));
+        assertTrue(timeWaiting < MILLISECONDS.toNanos(readyTestTimeout));
+      } catch (ExecutionException | InterruptedException ex) {
+        log.warn("Task failed", ex);
+        fail("Task failed with exception - " + ex.getMessage());
+      }
+    });
+  }
+
+  private static class ReadyTask implements Callable<Long> {
+
+    private final ReadyMonitor readyMonitor;
+    private final CountDownLatch readyToRunLatch;
+    private final CountDownLatch finishedLatch;
+
+    public ReadyTask(final ReadyMonitor readyMonitor, final CountDownLatch readyToRunLatch,
+        final CountDownLatch finishedLatch) {
+      this.readyMonitor = readyMonitor;
+      this.readyToRunLatch = readyToRunLatch;
+      this.finishedLatch = finishedLatch;
+    }
+
+    @Override
+    public Long call() throws Exception {
+      // signal ready to run
+      readyToRunLatch.countDown();
+      // time waiting for isReady to complete++
+      long start = System.nanoTime();
+      readyMonitor.isReady();
+      finishedLatch.countDown();
+
+      // returning nanoseconds.
+      return System.nanoTime() - start;
+    }
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ZooPropLoaderTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ZooPropLoaderTest.java
new file mode 100644
index 0000000000..84a809bd1c
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ZooPropLoaderTest.java
@@ -0,0 +1,665 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static org.apache.accumulo.core.conf.Property.GC_PORT;
+import static org.apache.accumulo.core.conf.Property.MANAGER_CLIENTPORT;
+import static org.apache.accumulo.core.conf.Property.TSERV_CLIENTPORT;
+import static org.apache.accumulo.core.conf.Property.TSERV_NATIVEMAP_ENABLED;
+import static org.apache.accumulo.core.conf.Property.TSERV_SCAN_MAX_OPENFILES;
+import static org.apache.accumulo.server.conf.store.impl.PropCacheCaffeineImpl.REFRESH_MIN;
+import static org.easymock.EasyMock.anyLong;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.capture;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.newCapture;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.easymock.Capture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ZooPropLoaderTest {
+
+  private static final Logger log = LoggerFactory.getLogger(ZooPropLoaderTest.class);
+
+  private PropCacheCaffeineImplTest.TestTicker ticker;
+  private InstanceId instanceId;
+  private ServerContext context;
+  private PropCacheKey<?> propCacheKey;
+  private VersionedPropCodec propCodec;
+
+  // mocks
+  private PropStoreMetrics cacheMetrics;
+  private PropStoreWatcher propStoreWatcher;
+  private ZooReaderWriter zrw;
+
+  private ZooPropLoader loader;
+
+  @BeforeEach
+  public void initCommonMocks() {
+    ticker = new PropCacheCaffeineImplTest.TestTicker();
+    instanceId = InstanceId.of(UUID.randomUUID());
+
+    propCacheKey = TablePropKey.of(instanceId, TableId.of("1abc"));
+    propCodec = VersionedPropCodec.getDefault();
+
+    // mocks
+    context = createMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+
+    zrw = createMock(ZooReaderWriter.class);
+
+    cacheMetrics = createMock(PropStoreMetrics.class);
+
+    propStoreWatcher = createMock(PropStoreWatcher.class);
+
+    // loader used in tests
+    loader = new ZooPropLoader(zrw, propCodec, propStoreWatcher, cacheMetrics);
+
+  }
+
+  @AfterEach
+  public void verifyCommonMocks() {
+    verify(context, zrw, propStoreWatcher, cacheMetrics);
+  }
+
+  @Test
+  public void loadTest() throws Exception {
+
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).anyTimes();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(1);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    assertNotNull(loader.load(propCacheKey));
+  }
+
+  // from cache loader
+
+  /**
+   * Verify that first call loads from ZooKeeper, then second call returns from the cache.
+   *
+   * @throws Exception
+   *           any exception is a test failure.
+   */
+  @Test
+  public void loadAndCacheTest() throws Exception {
+
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    expect(zrw.getStatus(propCacheKey.getPath())).andThrow(new KeeperException.NoNodeException())
+        .anyTimes();
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).once();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(1);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load into cache
+    assertNotNull(cache.get(propCacheKey));
+
+    // read cached entry - load count should not change.
+    ticker.advance(1, TimeUnit.MINUTES);
+    assertNotNull(cache.get(propCacheKey));
+  }
+
+  @Test
+  public void getExpireTimeoutTest() {
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+    // TODO check into implementing an explicit test.
+    // fail("Implement test");
+  }
+
+  /**
+   * Verify that an exception on load result in null value and that the exception does not escape
+   * the load call.
+   * <p>
+   * throws Exception any exception is a test failure.
+   */
+  @Test
+  public void loadFailTest() throws Exception {
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andThrow(new KeeperException.NoNodeException("force no node exception")).once();
+
+    propStoreWatcher.signalZkChangeEvent(eq(propCacheKey));
+    expectLastCall();
+
+    cacheMetrics.incrZkError();
+    expectLastCall().once();
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    assertNull(cache.get(propCacheKey));
+
+    log.debug("Metrics: {}", cacheMetrics);
+  }
+
+  /**
+   * Validate that cache expiration functions as expected.
+   * <p>
+   * throws Exception any exception is a test failure.
+   */
+  @Test
+  public void expireTest() throws Exception {
+
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).times(2);
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(2);
+
+    cacheMetrics.incrEviction();
+    expectLastCall().once();
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load cache
+    assertNotNull(cache.get(propCacheKey));
+
+    ticker.advance(70, TimeUnit.MINUTES);
+    cache.cleanUp();
+
+    assertNotNull(cache.get(propCacheKey));
+
+  }
+
+  /**
+   * Test that a ZooKeeper exception on an async reload task is correctly handed and that the value
+   * is removed from the cache when the refresh fails and the next get.
+   *
+   * @throws Exception
+   *           if a test error occurs.
+   */
+  @Test
+  public void reloadExceptionTest() throws Exception {
+
+    final VersionedProperties defaultProps = new VersionedProperties();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).once();
+
+    Stat stat = new Stat();
+    stat.setVersion(123); // set different version so reload triggered
+    expect(zrw.getStatus(propCacheKey.getPath())).andReturn(stat).once();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andThrow(new KeeperException.NoNodeException("forced no node")).anyTimes();
+
+    propStoreWatcher.signalZkChangeEvent(anyObject());
+    expectLastCall().anyTimes();
+
+    propStoreWatcher.signalCacheChangeEvent(anyObject());
+    expectLastCall().anyTimes();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(1);
+    cacheMetrics.incrRefresh();
+    expectLastCall().times(1);
+    cacheMetrics.incrRefreshLoad();
+    expectLastCall().times(1);
+    cacheMetrics.incrZkError();
+    expectLastCall().times(2);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // prime cache
+    assertNotNull(cache.get(propCacheKey));
+
+    ticker.advance(5, TimeUnit.MINUTES);
+    cache.cleanUp();
+
+    // read cached value
+    assertNotNull(cache.get(propCacheKey));
+
+    // advance so refresh called.
+    ticker.advance(20, TimeUnit.MINUTES);
+    cache.cleanUp();
+
+    assertNotNull(cache.get(propCacheKey));
+
+    // yield so async thread completes.
+    Thread.sleep(250);
+
+    assertNull(cache.get(propCacheKey));
+  }
+
+  @Test
+  public void getWithoutCachingTest() {
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    assertNull(cache.getWithoutCaching(propCacheKey));
+
+  }
+
+  @Test
+  public void removeTest() throws Exception {
+    final var sysPropKey = SystemPropKey.of(instanceId);
+    final var tablePropKey = TablePropKey.of(instanceId, TableId.of("t1"));
+
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    expect(zrw.getData(eq(sysPropKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).once();
+    expect(zrw.getData(eq(tablePropKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).once();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(2);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load into cache
+    assertNotNull(cache.get(sysPropKey));
+    assertNotNull(cache.get(tablePropKey));
+
+    cache.remove(tablePropKey);
+    cache.cleanUp();
+
+    // verify retrieved from cache without loading.
+    assertNotNull(cache.getWithoutCaching(sysPropKey));
+    assertNull(cache.getWithoutCaching(tablePropKey));
+  }
+
+  @Test
+  public void removeAllTest() throws Exception {
+    final var sysPropKey = SystemPropKey.of(instanceId);
+    final var tablePropKey = TablePropKey.of(instanceId, TableId.of("t1"));
+
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    expect(zrw.getData(eq(sysPropKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).once();
+    expect(zrw.getData(eq(tablePropKey.getPath()), anyObject(), anyObject()))
+        .andReturn(propCodec.toBytes(defaultProps)).once();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(2);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load into cache
+    assertNotNull(cache.get(sysPropKey));
+    assertNotNull(cache.get(tablePropKey));
+
+    cache.removeAll();
+    cache.cleanUp();
+
+    // verify retrieved from cache without loading.
+    assertNull(cache.getWithoutCaching(sysPropKey));
+    assertNull(cache.getWithoutCaching(tablePropKey));
+  }
+
+  @Test
+  public void getWithoutCachingNotPresentTest() {
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load into cache
+    assertNull(cache.getWithoutCaching(propCacheKey));
+  }
+
+  @Test
+  public void refreshTest() throws Exception {
+
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    // first call loads cache
+    Capture<Stat> stat = newCapture();
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), capture(stat))).andAnswer(() -> {
+      Stat s = stat.getValue();
+      s.setCtime(System.currentTimeMillis());
+      s.setMtime(System.currentTimeMillis());
+      s.setCzxid(1234);
+      s.setVersion(0);
+      stat.setValue(s);
+      return propCodec.toBytes(defaultProps);
+    }).times(1);
+
+    Stat expectedStat = new Stat();
+    expectedStat.setVersion(0);
+    expect(zrw.getStatus(propCacheKey.getPath())).andReturn(expectedStat).times(2);
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(1);
+    cacheMetrics.incrRefresh();
+    expectLastCall().times(2);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load cache
+    log.debug("received: {}", cache.get(propCacheKey));
+
+    ticker.advance(REFRESH_MIN + 1, TimeUnit.MINUTES);
+
+    assertNotNull(cache.get(propCacheKey));
+
+    ticker.advance(REFRESH_MIN / 2, TimeUnit.MINUTES);
+
+    assertNotNull(cache.get(propCacheKey));
+
+    Thread.sleep(100);
+
+    ticker.advance(REFRESH_MIN + 1, TimeUnit.MINUTES);
+
+    assertNotNull(cache.get(propCacheKey));
+
+    Thread.sleep(100);
+
+    ticker.advance(1, TimeUnit.MINUTES);
+
+    assertNotNull(cache.get(propCacheKey));
+
+  }
+
+  /**
+   * Test that when the refreshAfterWrite period expires that the data version is checked against
+   * stored value - and on mismatch, rereads the values from ZooKeeper.
+   */
+  @Test
+  public void refreshDifferentVersionTest() throws Exception {
+
+    final int initialVersion = 123;
+    Capture<PropStoreWatcher> propStoreWatcherCapture = newCapture();
+
+    Capture<Stat> stat = newCapture();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), capture(propStoreWatcherCapture), capture(stat)))
+        .andAnswer(() -> {
+          Stat s = stat.getValue();
+          s.setCtime(System.currentTimeMillis());
+          s.setMtime(System.currentTimeMillis());
+          s.setVersion(initialVersion + 1);
+          stat.setValue(s);
+          return propCodec.toBytes(new VersionedProperties(initialVersion + 1, Instant.now(),
+              Map.of(Property.TABLE_SPLIT_THRESHOLD.getKey(), "7G")));
+        }).once();
+
+    // make it look like version on ZK has advanced.
+    Stat stat2 = new Stat();
+    stat2.setVersion(initialVersion + 3); // initSysProps 123, on write 124
+    expect(zrw.getStatus(propCacheKey.getPath())).andReturn(stat2).once();
+
+    Capture<Stat> stat3 = newCapture();
+
+    expect(
+        zrw.getData(eq(propCacheKey.getPath()), capture(propStoreWatcherCapture), capture(stat3)))
+            .andAnswer(() -> {
+              Stat s = stat3.getValue();
+              s.setCtime(System.currentTimeMillis());
+              s.setMtime(System.currentTimeMillis());
+              s.setVersion(initialVersion + 4);
+              stat3.setValue(s);
+              return propCodec.toBytes(new VersionedProperties(initialVersion + 3, Instant.now(),
+                  Map.of(Property.TABLE_SPLIT_THRESHOLD.getKey(), "12G")));
+            }).once();
+
+    propStoreWatcher.signalCacheChangeEvent(eq(propCacheKey));
+    expectLastCall();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(2);
+
+    cacheMetrics.incrRefresh();
+    expectLastCall().times(1);
+
+    cacheMetrics.incrRefreshLoad();
+    expectLastCall().times(1);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // prime cache
+    assertNotNull(cache.get(propCacheKey));
+
+    ticker.advance(REFRESH_MIN + 1, TimeUnit.MINUTES);
+    // first call after refresh return original and schedules update
+    var originalProps = cache.get(propCacheKey);
+    assertNotNull(originalProps);
+    assertNotNull(originalProps.asMap().get(Property.TABLE_SPLIT_THRESHOLD.getKey()));
+
+    // allow refresh thread to run
+    Thread.sleep(50);
+
+    // refresh should have loaded updated value;
+    var updatedProps = cache.get(propCacheKey);
+    log.debug("Updated props: {}", updatedProps == null ? "null" : updatedProps.print(true));
+
+    assertNotNull(updatedProps);
+    Thread.sleep(250);
+
+    assertEquals("12G", updatedProps.asMap().get(Property.TABLE_SPLIT_THRESHOLD.getKey()));
+  }
+
+  /**
+   * Test that when the refreshAfterWrite period expires that the data version is checked against
+   * stored value - and on match, returns the current value without rereading the values from
+   * ZooKeeper.
+   *
+   * @throws Exception
+   *           any exception is a test failure
+   */
+  @Test
+  public void refreshSameVersionTest() throws Exception {
+
+    final int expectedVersion = 123;
+
+    VersionedProperties mockProps = createMock(VersionedProperties.class);
+    expect(mockProps.getTimestamp()).andReturn(Instant.now()).once();
+    expect(mockProps.asMap()).andReturn(Map.of());
+
+    Capture<Stat> stat = newCapture();
+
+    // first call loads cache
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), capture(stat))).andAnswer(() -> {
+      Stat s = stat.getValue();
+      s.setCtime(System.currentTimeMillis());
+      s.setMtime(System.currentTimeMillis());
+      s.setVersion(expectedVersion);
+      stat.setValue(s);
+      return propCodec.toBytes(mockProps);
+    }).times(1);
+
+    Stat stat2 = new Stat();
+    stat2.setCtime(System.currentTimeMillis());
+    stat2.setMtime(System.currentTimeMillis());
+    stat2.setVersion(expectedVersion);
+
+    expect(zrw.getStatus(propCacheKey.getPath())).andReturn(stat2).once();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(1);
+    cacheMetrics.incrRefresh();
+    expectLastCall().times(1);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics, mockProps);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // prime cache
+    cache.get(propCacheKey);
+
+    ticker.advance(30, TimeUnit.MINUTES);
+    cache.cleanUp();
+
+    VersionedProperties vPropsRead = cache.get(propCacheKey);
+
+    assertNotNull(vPropsRead);
+
+    Thread.sleep(250);
+    cache.get(propCacheKey);
+
+    verify(mockProps);
+  }
+
+  /**
+   * reload exception - exception thrown reading Stat to check version.
+   *
+   * @throws Exception
+   *           any exception is a test failure.
+   */
+  @Test
+  public void refreshExceptionTest() throws Exception {
+    VersionedProperties defaultProps = new VersionedProperties();
+
+    // first call loads cache
+    Capture<Stat> stat = newCapture();
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), capture(stat))).andAnswer(() -> {
+      Stat s = stat.getValue();
+      s.setCtime(System.currentTimeMillis());
+      s.setMtime(System.currentTimeMillis());
+      s.setCzxid(1234);
+      s.setVersion(0);
+      stat.setValue(s);
+      return propCodec.toBytes(defaultProps);
+    }).times(1);
+
+    Stat expectedStat = new Stat();
+    expectedStat.setVersion(0);
+    expect(zrw.getStatus(propCacheKey.getPath()))
+        .andThrow(new KeeperException.NoNodeException("force no node exception")).once();
+
+    propStoreWatcher.signalZkChangeEvent(eq(propCacheKey));
+    expectLastCall().anyTimes();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(), anyObject()))
+        .andThrow(new KeeperException.NoNodeException("force no node exception")).once();
+
+    cacheMetrics.addLoadTime(anyLong());
+    expectLastCall().times(1);
+    cacheMetrics.incrRefresh();
+    expectLastCall().times(1);
+    cacheMetrics.incrZkError();
+    expectLastCall().times(2);
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(loader, cacheMetrics).withTicker(ticker).build();
+
+    // load cache
+    log.debug("received: {}", cache.get(propCacheKey));
+
+    ticker.advance(REFRESH_MIN + 1, TimeUnit.MINUTES);
+
+    assertNotNull(cache.get(propCacheKey)); // returns current and queues async refresh
+
+    Thread.sleep(150);
+    assertNull(cache.get(propCacheKey)); // on exception, the loader should return null
+
+  }
+
+  @Test
+  public void captureExampleTest() throws Exception {
+
+    Map<String,String> props = new HashMap<>();
+    props.put(TSERV_CLIENTPORT.getKey(), "1234");
+    props.put(TSERV_NATIVEMAP_ENABLED.getKey(), "false");
+    props.put(TSERV_SCAN_MAX_OPENFILES.getKey(), "2345");
+    props.put(MANAGER_CLIENTPORT.getKey(), "3456");
+    props.put(GC_PORT.getKey(), "4567");
+    VersionedProperties vProps = new VersionedProperties(8, Instant.now(), props);
+
+    Capture<String> path = newCapture();
+    Capture<Stat> stat = newCapture();
+
+    expect(zrw.getData(capture(path), anyObject(), capture(stat))).andAnswer(() -> {
+      Stat r = stat.getValue();
+      r.setCzxid(1234);
+      r.setVersion(9);
+      stat.setValue(r);
+      return propCodec.toBytes(vProps);
+    }).anyTimes();
+
+    replay(context, zrw, propStoreWatcher, cacheMetrics);
+
+    Stat statCheck = new Stat();
+    statCheck.setVersion(9);
+
+  }
+
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ZooPropStoreTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ZooPropStoreTest.java
new file mode 100644
index 0000000000..a7dad16ffa
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/store/impl/ZooPropStoreTest.java
@@ -0,0 +1,403 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.store.impl;
+
+import static org.apache.accumulo.core.conf.Property.TABLE_BULK_MAX_TABLETS;
+import static org.apache.accumulo.core.conf.Property.TABLE_FILE_BLOCK_SIZE;
+import static org.apache.accumulo.core.conf.Property.TABLE_SPLIT_THRESHOLD;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.capture;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.newCapture;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.data.Stat;
+import org.easymock.Capture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ZooPropStoreTest {
+
+  private final VersionedPropCodec propCodec = VersionedPropCodec.getDefault();
+  private InstanceId instanceId;
+
+  // mocks
+  private ServerContext context;
+  private ZooReaderWriter zrw;
+
+  @BeforeEach
+  public void init() throws Exception {
+    instanceId = InstanceId.of(UUID.randomUUID());
+    context = createMock(ServerContext.class);
+    zrw = createMock(ZooReaderWriter.class);
+    expect(context.getZooReaderWriter()).andReturn(zrw).anyTimes();
+    expect(zrw.getSessionTimeout()).andReturn(2_000).anyTimes();
+    expect(context.getInstanceID()).andReturn(instanceId).anyTimes();
+
+    expect(zrw.exists(eq("/accumulo/" + instanceId), anyObject())).andReturn(true).anyTimes();
+  }
+
+  @AfterEach
+  public void verifyMock() {
+    verify(context, zrw);
+  }
+
+  @Test
+  public void create() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("propCacheKey"));
+
+    Capture<byte[]> bytes = newCapture();
+    expect(zrw.putPersistentData(eq(propCacheKey.getPath()), capture(bytes), anyObject()))
+        .andReturn(true).once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+
+    propStore.create(propCacheKey,
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M"));
+
+    var decoded = propCodec.fromBytes(0, bytes.getValue());
+    assertNotNull(decoded);
+    assertEquals(0, decoded.getDataVersion());
+    assertEquals("1234", decoded.asMap().get(TABLE_BULK_MAX_TABLETS.getKey()));
+    assertEquals("512M", decoded.asMap().get(TABLE_FILE_BLOCK_SIZE.getKey()));
+  }
+
+  /**
+   * Simple get tests load from ZooKeeper and then retrieved from cache.
+   *
+   * @throws Exception
+   *           any exception is a test error.
+   */
+  @Test
+  public void getTest() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("propCacheKey"));
+
+    var vProps = new VersionedProperties(Map.of(Property.TABLE_BLOOM_ENABLED.getKey(), "true"));
+
+    // expect one ZooKeeper call - subsequent calls should load from cache.
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(PropStoreWatcher.class), anyObject()))
+        .andReturn(VersionedPropCodec.getDefault().toBytes(vProps)).once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+
+    assertNotNull(propStore.get(propCacheKey)); // first call will fetch from ZooKeeper
+    assertNotNull(propStore.get(propCacheKey)); // next call will fetch from cache.
+
+    var p = propStore.get(propCacheKey);
+
+    assertEquals("true", p.asMap().get(Property.TABLE_BLOOM_ENABLED.getKey()));
+  }
+
+  @Test
+  public void versionTest() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("table1"));
+    Map<String,String> props =
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M");
+
+    Capture<PropStoreWatcher> propStoreWatcherCapture = newCapture();
+    Capture<Stat> stat = newCapture();
+
+    // force version mismatch between zk and props.
+    var expectedVersion = 99;
+    expect(zrw.getData(eq(propCacheKey.getPath()), capture(propStoreWatcherCapture), capture(stat)))
+        .andAnswer(() -> {
+          Stat s = stat.getValue();
+          s.setCtime(System.currentTimeMillis());
+          s.setMtime(System.currentTimeMillis());
+          s.setVersion(expectedVersion);
+          stat.setValue(s);
+          return propCodec.toBytes(new VersionedProperties(props));
+        }).once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+    var vProps = propStore.get(propCacheKey);
+    assertNotNull(vProps);
+    assertEquals(expectedVersion, vProps.getDataVersion());
+  }
+
+  /**
+   * Test that putAll will add and also overwrite properties.
+   *
+   * @throws Exception
+   *           any exception is a test error.
+   */
+  @Test
+  public void putAllTest() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("table1"));
+
+    var initialProps = new VersionedProperties(0, Instant.now(),
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M"));
+
+    // not cached - will load from ZooKeeper
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(Stat.class)))
+        .andReturn(propCodec.toBytes(initialProps)).once();
+
+    Capture<byte[]> bytes = newCapture();
+    expect(zrw.overwritePersistentData(eq(propCacheKey.getPath()), capture(bytes), eq(0)))
+        .andAnswer(() -> {
+          var stored = propCodec.fromBytes(0, bytes.getValue());
+          assertEquals(3, stored.asMap().size());
+          // overwritten
+          assertEquals("4321", stored.asMap().get(TABLE_BULK_MAX_TABLETS.getKey()));
+          // unchanged
+          assertEquals("512M", stored.asMap().get(TABLE_FILE_BLOCK_SIZE.getKey()));
+          // new
+          assertEquals("123M", stored.asMap().get(TABLE_SPLIT_THRESHOLD.getKey()));
+          return true;
+        }).once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+
+    Map<String,String> updateProps =
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "4321", TABLE_SPLIT_THRESHOLD.getKey(), "123M");
+
+    propStore.putAll(propCacheKey, updateProps);
+
+    verify(zrw);
+  }
+
+  @Test
+  public void removeTest() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("table1"));
+
+    var initialProps = new VersionedProperties(123, Instant.now(),
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M"));
+
+    // not cached - will load from ZooKeeper
+    Capture<Stat> stat = newCapture();
+
+    expect(zrw.getData(eq(propCacheKey.getPath()), capture(stat))).andAnswer(() -> {
+      Stat s = stat.getValue();
+      s.setVersion(123);
+      stat.setValue(s);
+      return propCodec.toBytes(initialProps);
+    }).once();
+
+    Capture<byte[]> bytes = newCapture();
+    expect(zrw.overwritePersistentData(eq(propCacheKey.getPath()), capture(bytes), eq(123)))
+        .andAnswer(() -> {
+          var stored = propCodec.fromBytes(124, bytes.getValue());
+          assertEquals(1, stored.asMap().size());
+          // deleted
+          assertNull(stored.asMap().get(TABLE_BULK_MAX_TABLETS.getKey()));
+          // unchanged
+          assertEquals("512M", stored.asMap().get(TABLE_FILE_BLOCK_SIZE.getKey()));
+          // never existed
+          assertNull(stored.asMap().get(TABLE_SPLIT_THRESHOLD.getKey()));
+          return true;
+        }).once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+
+    Set<String> deleteNames =
+        Set.of(TABLE_BULK_MAX_TABLETS.getKey(), TABLE_SPLIT_THRESHOLD.getKey());
+
+    propStore.removeProperties(propCacheKey, deleteNames);
+    verify(zrw);
+  }
+
+  @Test
+  public void removeWithExceptionsTest() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("table1"));
+
+    // return "bad data"
+    Capture<Stat> stat = newCapture();
+    expect(zrw.getData(eq(propCacheKey.getPath()), capture(stat))).andAnswer(() -> {
+      Stat s = stat.getValue();
+      s.setCtime(System.currentTimeMillis());
+      s.setMtime(System.currentTimeMillis());
+      s.setCzxid(1234);
+      s.setVersion(19);
+      stat.setValue(s);
+      return new byte[100];
+    }).once();
+
+    // mock throwing exceptions
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(Stat.class)))
+        .andThrow(new KeeperException.NoNodeException("mock forced no node")).once();
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(Stat.class)))
+        .andThrow(new InterruptedException("mock forced interrupt exception")).once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+
+    Set<String> deleteNames =
+        Set.of(TABLE_BULK_MAX_TABLETS.getKey(), TABLE_SPLIT_THRESHOLD.getKey());
+
+    // Parse error converted to IllegalStateException
+    assertThrows(IllegalStateException.class,
+        () -> propStore.removeProperties(propCacheKey, deleteNames));
+    // ZK exception converted to IllegalStateException
+    assertThrows(IllegalStateException.class,
+        () -> propStore.removeProperties(propCacheKey, deleteNames));
+    // InterruptException converted to IllegalStateException
+    assertThrows(IllegalStateException.class,
+        () -> propStore.removeProperties(propCacheKey, deleteNames));
+  }
+
+  @Test
+  public void validateWatcherSetTest() throws Exception {
+
+    var tablePropKey = TablePropKey.of(instanceId, TableId.of("table1"));
+    Map<String,String> props =
+        Map.of(TABLE_BULK_MAX_TABLETS.getKey(), "1234", TABLE_FILE_BLOCK_SIZE.getKey(), "512M");
+
+    Capture<PropStoreWatcher> propStoreWatcherCapture = newCapture();
+    Capture<Stat> stat = newCapture();
+
+    // first call loads cache
+    expect(zrw.getData(eq(tablePropKey.getPath()), capture(propStoreWatcherCapture), capture(stat)))
+        .andAnswer(() -> {
+          Stat s = stat.getValue();
+          s.setCtime(System.currentTimeMillis());
+          s.setMtime(System.currentTimeMillis());
+          s.setVersion(13);
+          stat.setValue(s);
+          return propCodec.toBytes(new VersionedProperties(12, Instant.now(), props));
+        }).once();
+
+    // watcher change (re)loads cache on next get call.
+    expect(zrw.getData(eq(tablePropKey.getPath()), capture(propStoreWatcherCapture), capture(stat)))
+        .andAnswer(() -> {
+          Stat s = stat.getValue();
+          s.setCtime(System.currentTimeMillis());
+          s.setMtime(System.currentTimeMillis());
+          s.setVersion(14);
+          stat.setValue(s);
+          return propCodec.toBytes(new VersionedProperties(13, Instant.now(), props));
+        }).once();
+
+    replay(context, zrw);
+
+    ReadyMonitor monitor = new TestReadyMonitor("testmon", 2000);
+    PropStoreWatcher watcher = new TestWatcher(monitor);
+
+    ZooPropStore propStore = new ZooPropStore(instanceId, zrw, monitor, watcher, null);
+
+    assertNotNull(propStore.get(tablePropKey));
+
+    // second call should return from cache - not zookeeper.
+    assertNotNull(propStore.get(tablePropKey));
+    PropStoreWatcher capturedMonitor = propStoreWatcherCapture.getValue();
+    capturedMonitor.signalZkChangeEvent(tablePropKey);
+    Thread.sleep(150);
+
+    assertNotNull(propStore.get(tablePropKey));
+
+  }
+
+  private static class TestReadyMonitor extends ReadyMonitor {
+
+    /**
+     * Create an instance of a ready monitor.
+     *
+     * @param resourceName
+     *          the resource name guarded by this monitor (used by logging)
+     * @param timeout
+     *          the max time in milliseconds this will block waiting.
+     */
+    public TestReadyMonitor(String resourceName, long timeout) {
+      super(resourceName, timeout);
+      setReady();
+    }
+  }
+
+  private static class TestWatcher extends PropStoreWatcher {
+    public TestWatcher(ReadyMonitor zkReadyMonitor) {
+      super(zkReadyMonitor);
+    }
+  }
+
+  /**
+   * Verify that a node is created when it does not exist.
+   */
+  // @Test
+  public void getNoNodeTest() {
+    // TODO - implementation requires mocking transform locking
+  }
+
+  @Test
+  public void deleteTest() throws Exception {
+
+    var propCacheKey = TablePropKey.of(instanceId, TableId.of("propCacheKey"));
+
+    var vProps = new VersionedProperties(Map.of(Property.TABLE_BLOOM_ENABLED.getKey(), "true"));
+
+    // expect first call to load cache.
+    expect(zrw.getData(eq(propCacheKey.getPath()), anyObject(PropStoreWatcher.class), anyObject()))
+        .andReturn(VersionedPropCodec.getDefault().toBytes(vProps)).once();
+
+    zrw.delete(eq(propCacheKey.getPath()));
+    expectLastCall().once();
+
+    replay(context, zrw);
+
+    PropStore propStore = ZooPropStore.initialize(instanceId, zrw);
+
+    assertNotNull(propStore.get(propCacheKey)); // first call will fetch from ZooKeeper
+    assertNotNull(propStore.get(propCacheKey)); // next call will fetch from cache.
+    var p = propStore.get(propCacheKey);
+
+    assertEquals("true", p.asMap().get(Property.TABLE_BLOOM_ENABLED.getKey()));
+
+    propStore.delete(propCacheKey);
+    Thread.sleep(50);
+  }
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/conf/util/PropSnapshotTest.java b/server/base/src/test/java/org/apache/accumulo/server/conf/util/PropSnapshotTest.java
new file mode 100644
index 0000000000..8017a38901
--- /dev/null
+++ b/server/base/src/test/java/org/apache/accumulo/server/conf/util/PropSnapshotTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.server.conf.util;
+
+import static org.apache.accumulo.core.conf.Property.TABLE_BLOOM_ENABLED;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.SystemPropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class PropSnapshotTest {
+
+  private InstanceId instanceId;
+  private PropStore propStore;
+
+  @BeforeEach
+  public void init() {
+    instanceId = InstanceId.of(UUID.randomUUID());
+    propStore = createMock(ZooPropStore.class);
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+  }
+
+  @AfterEach
+  public void verifyMocks() {
+    verify(propStore);
+  }
+
+  @Test
+  public void getTest() {
+    // init props
+    expect(propStore.get(eq(SystemPropKey.of(instanceId))))
+        .andReturn(new VersionedProperties(123, Instant.now(), Map.of("k1", "v1", "k2", "v2")))
+        .once();
+    // after update
+    expect(propStore.get(eq(SystemPropKey.of(instanceId))))
+        .andReturn(new VersionedProperties(124, Instant.now(), Map.of("k3", "v3"))).once();
+
+    replay(propStore);
+    PropSnapshot snapshot = PropSnapshot.create(SystemPropKey.of(instanceId), propStore);
+
+    assertEquals("v1", snapshot.getVersionedProperties().asMap().get("k1"));
+    assertEquals("v2", snapshot.getVersionedProperties().asMap().get("k2"));
+    assertNull(snapshot.getVersionedProperties().asMap().get("k3"));
+
+    snapshot.requireUpdate();
+
+    assertEquals("v3", snapshot.getVersionedProperties().asMap().get("k3"));
+    assertNull(snapshot.getVersionedProperties().asMap().get("k1"));
+    assertNull(snapshot.getVersionedProperties().asMap().get("k2"));
+  }
+
+  @Test
+  public void eventChangeTest() {
+
+    var sysPropKey = SystemPropKey.of(instanceId);
+
+    expect(propStore.get(eq(sysPropKey))).andReturn(
+        new VersionedProperties(99, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true")))
+        .once();
+
+    expect(propStore.get(eq(sysPropKey))).andReturn(
+        new VersionedProperties(100, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "false")))
+        .once();
+
+    replay(propStore);
+
+    PropSnapshot snapshot = PropSnapshot.create(sysPropKey, propStore);
+
+    assertEquals("true",
+        snapshot.getVersionedProperties().asMap().get(TABLE_BLOOM_ENABLED.getKey()));
+    snapshot.zkChangeEvent(sysPropKey);
+    assertEquals("false",
+        snapshot.getVersionedProperties().asMap().get(TABLE_BLOOM_ENABLED.getKey()));
+  }
+
+  @Test
+  public void deleteEventTest() {
+
+    var sysPropKey = SystemPropKey.of(instanceId);
+
+    expect(propStore.get(eq(sysPropKey))).andReturn(
+        new VersionedProperties(123, Instant.now(), Map.of(TABLE_BLOOM_ENABLED.getKey(), "true")))
+        .once();
+
+    expect(propStore.get(eq(sysPropKey))).andThrow(new IllegalStateException("Fake node delete"))
+        .once();
+
+    replay(propStore);
+    PropSnapshot snapshot = PropSnapshot.create(sysPropKey, propStore);
+
+    assertEquals("true",
+        snapshot.getVersionedProperties().asMap().get(TABLE_BLOOM_ENABLED.getKey()));
+    snapshot.deleteEvent(sysPropKey);
+    assertThrows(IllegalStateException.class, () -> snapshot.getVersionedProperties());
+  }
+
+}
diff --git a/server/base/src/test/java/org/apache/accumulo/server/master/balancer/BaseHostRegexTableLoadBalancerTest.java b/server/base/src/test/java/org/apache/accumulo/server/master/balancer/BaseHostRegexTableLoadBalancerTest.java
index cf767c54c5..c58ac3bffd 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/master/balancer/BaseHostRegexTableLoadBalancerTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/master/balancer/BaseHostRegexTableLoadBalancerTest.java
@@ -18,7 +18,12 @@
  */
 package org.apache.accumulo.server.master.balancer;
 
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.eq;
 import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
 
 import java.net.UnknownHostException;
 import java.util.ArrayList;
@@ -30,6 +35,7 @@ import java.util.SortedMap;
 import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
+import java.util.UUID;
 import java.util.function.Predicate;
 
 import org.apache.accumulo.core.Constants;
@@ -43,6 +49,7 @@ import org.apache.accumulo.core.conf.DefaultConfiguration;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.conf.SiteConfiguration;
 import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.NamespaceId;
 import org.apache.accumulo.core.data.TableId;
 import org.apache.accumulo.core.dataImpl.KeyExtent;
 import org.apache.accumulo.core.master.thrift.TableInfo;
@@ -53,6 +60,11 @@ import org.apache.accumulo.server.ServerContext;
 import org.apache.accumulo.server.conf.NamespaceConfiguration;
 import org.apache.accumulo.server.conf.ServerConfigurationFactory;
 import org.apache.accumulo.server.conf.TableConfiguration;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.NamespacePropKey;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
 import org.apache.hadoop.io.Text;
 import org.easymock.EasyMock;
 
@@ -114,7 +126,7 @@ public abstract class BaseHostRegexTableLoadBalancerTest extends HostRegexTableL
     public TableConfiguration getTableConfiguration(final TableId tableId) {
       // create a dummy namespaceConfiguration to satisfy requireNonNull in TableConfiguration
       // constructor
-      NamespaceConfiguration dummyConf = new NamespaceConfiguration(Namespace.DEFAULT.id(), context,
+      NamespaceConfiguration dummyConf = new NamespaceConfiguration(context, Namespace.DEFAULT.id(),
           DefaultConfiguration.getInstance());
       return new TableConfiguration(context, tableId, dummyConf) {
         @Override
@@ -158,13 +170,34 @@ public abstract class BaseHostRegexTableLoadBalancerTest extends HostRegexTableL
   }
 
   protected ServerContext createMockContext() {
-    ServerContext mockContext = EasyMock.createMock(ServerContext.class);
+    InstanceId instanceId = InstanceId.of(UUID.randomUUID());
+
+    ServerContext mockContext = createMock(ServerContext.class);
+    PropStore propStore = createMock(ZooPropStore.class);
     expect(mockContext.getProperties()).andReturn(new Properties()).anyTimes();
     expect(mockContext.getZooKeepers()).andReturn("").anyTimes();
     expect(mockContext.getInstanceName()).andReturn("test").anyTimes();
     expect(mockContext.getZooKeepersSessionTimeOut()).andReturn(30).anyTimes();
-    expect(mockContext.getInstanceID()).andReturn(InstanceId.of("1111")).anyTimes();
+    expect(mockContext.getInstanceID()).andReturn(instanceId).anyTimes();
     expect(mockContext.getZooKeeperRoot()).andReturn(Constants.ZROOT + "/1111").anyTimes();
+
+    expect(mockContext.getPropStore()).andReturn(propStore).anyTimes();
+    propStore.registerAsListener(anyObject(), anyObject());
+    expectLastCall().anyTimes();
+
+    expect(propStore.get(eq(NamespacePropKey.of(instanceId, NamespaceId.of("+default")))))
+        .andReturn(new VersionedProperties()).anyTimes();
+
+    expect(propStore.get(eq(TablePropKey.of(instanceId, TableId.of("1")))))
+        .andReturn(new VersionedProperties()).anyTimes();
+
+    expect(propStore.get(eq(TablePropKey.of(instanceId, TableId.of("2")))))
+        .andReturn(new VersionedProperties()).anyTimes();
+
+    expect(propStore.get(eq(TablePropKey.of(instanceId, TableId.of("3")))))
+        .andReturn(new VersionedProperties()).anyTimes();
+
+    replay(propStore);
     return mockContext;
   }
 
diff --git a/server/base/src/test/java/org/apache/accumulo/server/security/UserImpersonationTest.java b/server/base/src/test/java/org/apache/accumulo/server/security/UserImpersonationTest.java
index fad93761ef..f35ea8f1d6 100644
--- a/server/base/src/test/java/org/apache/accumulo/server/security/UserImpersonationTest.java
+++ b/server/base/src/test/java/org/apache/accumulo/server/security/UserImpersonationTest.java
@@ -64,6 +64,11 @@ public class UserImpersonationTest {
       public void getProperties(Map<String,String> props, Predicate<String> filter) {
         cc.getProperties(props, filter);
       }
+
+      @Override
+      public long getUpdateCount() {
+        return 0;
+      }
     };
   }
 
diff --git a/server/manager/src/main/java/org/apache/accumulo/manager/ManagerClientServiceHandler.java b/server/manager/src/main/java/org/apache/accumulo/manager/ManagerClientServiceHandler.java
index 1e2e5886de..020b7bc6e7 100644
--- a/server/manager/src/main/java/org/apache/accumulo/manager/ManagerClientServiceHandler.java
+++ b/server/manager/src/main/java/org/apache/accumulo/manager/ManagerClientServiceHandler.java
@@ -30,6 +30,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
@@ -83,13 +84,10 @@ import org.apache.accumulo.server.client.ClientServiceHandler;
 import org.apache.accumulo.server.manager.LiveTServerSet.TServerConnection;
 import org.apache.accumulo.server.replication.proto.Replication.Status;
 import org.apache.accumulo.server.security.delegation.AuthenticationTokenSecretManager;
-import org.apache.accumulo.server.util.NamespacePropUtil;
 import org.apache.accumulo.server.util.SystemPropUtil;
-import org.apache.accumulo.server.util.TablePropUtil;
 import org.apache.hadoop.io.Text;
 import org.apache.hadoop.security.token.Token;
 import org.apache.thrift.TException;
-import org.apache.zookeeper.KeeperException;
 import org.apache.zookeeper.KeeperException.NoNodeException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -397,19 +395,16 @@ public class ManagerClientServiceHandler implements ManagerClientService.Iface {
 
     try {
       if (value == null) {
-        NamespacePropUtil.removeNamespaceProperty(manager.getContext(), namespaceId, property);
+        manager.getContext().namespacePropUtil().removeProperties(namespaceId, List.of(property));
       } else {
-        NamespacePropUtil.setNamespaceProperty(manager.getContext(), namespaceId, property, value);
+        manager.getContext().namespacePropUtil().setProperties(namespaceId,
+            Map.of(property, value));
       }
-    } catch (KeeperException.NoNodeException e) {
-      // race condition... namespace no longer exists? This call will throw an exception if the
-      // namespace was deleted:
+    } catch (IllegalStateException ex) {
+      // race condition on delete... namespace no longer exists? An undelying ZooKeeper.NoNode
+      // exception will be thrown an exception if the namespace was deleted:
       ClientServiceHandler.checkNamespaceId(manager.getContext(), namespace, op);
-      log.info("Error altering namespace property", e);
-      throw new ThriftTableOperationException(namespaceId.canonical(), namespace, op,
-          TableOperationExceptionType.OTHER, "Problem altering namespaceproperty");
-    } catch (Exception e) {
-      log.error("Problem altering namespace property", e);
+      log.info("Error altering namespace property", ex);
       throw new ThriftTableOperationException(namespaceId.canonical(), namespace, op,
           TableOperationExceptionType.OTHER, "Problem altering namespace property");
     }
@@ -424,21 +419,19 @@ public class ManagerClientServiceHandler implements ManagerClientService.Iface {
 
     try {
       if (value == null || value.isEmpty()) {
-        TablePropUtil.removeTableProperty(manager.getContext(), tableId, property);
-      } else if (!TablePropUtil.setTableProperty(manager.getContext(), tableId, property, value)) {
-        throw new Exception("Invalid table property.");
+        manager.getContext().tablePropUtil().removeProperties(tableId, List.of(property));
+      } else {
+        manager.getContext().tablePropUtil().setProperties(tableId, Map.of(property, value));
       }
-    } catch (KeeperException.NoNodeException e) {
+    } catch (IllegalStateException ex) {
+      log.warn("Invalid table property, tried to set: tableId: " + tableId.canonical() + " to: "
+          + property + "=" + value);
       // race condition... table no longer exists? This call will throw an exception if the table
       // was deleted:
       ClientServiceHandler.checkTableId(manager.getContext(), tableName, op);
-      log.info("Error altering table property", e);
-      throw new ThriftTableOperationException(tableId.canonical(), tableName, op,
-          TableOperationExceptionType.OTHER, "Problem altering table property");
-    } catch (Exception e) {
-      log.error("Problem altering table property", e);
       throw new ThriftTableOperationException(tableId.canonical(), tableName, op,
-          TableOperationExceptionType.OTHER, "Problem altering table property");
+          TableOperationExceptionType.OTHER, "Invalid table property, tried to set: tableId: "
+              + tableId.canonical() + " to: " + property + "=" + value);
     }
   }
 
diff --git a/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/create/PopulateZookeeper.java b/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/create/PopulateZookeeper.java
index 7efa000dcd..38137196e9 100644
--- a/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/create/PopulateZookeeper.java
+++ b/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/create/PopulateZookeeper.java
@@ -18,8 +18,6 @@
  */
 package org.apache.accumulo.manager.tableOps.create;
 
-import java.util.Map.Entry;
-
 import org.apache.accumulo.core.clientImpl.thrift.TableOperation;
 import org.apache.accumulo.core.clientImpl.thrift.TableOperationExceptionType;
 import org.apache.accumulo.core.clientImpl.thrift.ThriftTableOperationException;
@@ -28,7 +26,6 @@ import org.apache.accumulo.manager.Manager;
 import org.apache.accumulo.manager.tableOps.ManagerRepo;
 import org.apache.accumulo.manager.tableOps.TableInfo;
 import org.apache.accumulo.manager.tableOps.Utils;
-import org.apache.accumulo.server.util.TablePropUtil;
 
 class PopulateZookeeper extends ManagerRepo {
 
@@ -59,13 +56,13 @@ class PopulateZookeeper extends ManagerRepo {
       manager.getTableManager().addTable(tableInfo.getTableId(), tableInfo.getNamespaceId(),
           tableInfo.getTableName());
 
-      for (Entry<String,String> entry : tableInfo.props.entrySet()) {
-        if (!TablePropUtil.setTableProperty(manager.getContext(), tableInfo.getTableId(),
-            entry.getKey(), entry.getValue())) {
-          throw new ThriftTableOperationException(null, tableInfo.getTableName(),
-              TableOperation.CREATE, TableOperationExceptionType.OTHER,
-              "Property or value not valid " + entry.getKey() + "=" + entry.getValue());
-        }
+      try {
+        manager.getContext().tablePropUtil().setProperties(tableInfo.getTableId(), tableInfo.props);
+      } catch (IllegalStateException ex) {
+        throw new ThriftTableOperationException(null, tableInfo.getTableName(),
+            TableOperation.CREATE, TableOperationExceptionType.OTHER,
+            "Property or value not valid for create " + tableInfo.getTableName() + " in "
+                + tableInfo.props);
       }
 
       manager.getContext().clearTableListCache();
diff --git a/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/namespace/create/PopulateZookeeperWithNamespace.java b/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/namespace/create/PopulateZookeeperWithNamespace.java
index 4731b4f0c4..0011123e97 100644
--- a/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/namespace/create/PopulateZookeeperWithNamespace.java
+++ b/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/namespace/create/PopulateZookeeperWithNamespace.java
@@ -18,8 +18,6 @@
  */
 package org.apache.accumulo.manager.tableOps.namespace.create;
 
-import java.util.Map.Entry;
-
 import org.apache.accumulo.core.clientImpl.thrift.TableOperation;
 import org.apache.accumulo.fate.Repo;
 import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeExistsPolicy;
@@ -27,13 +25,12 @@ import org.apache.accumulo.manager.Manager;
 import org.apache.accumulo.manager.tableOps.ManagerRepo;
 import org.apache.accumulo.manager.tableOps.Utils;
 import org.apache.accumulo.server.tables.TableManager;
-import org.apache.accumulo.server.util.NamespacePropUtil;
 
 class PopulateZookeeperWithNamespace extends ManagerRepo {
 
   private static final long serialVersionUID = 1L;
 
-  private NamespaceInfo namespaceInfo;
+  private final NamespaceInfo namespaceInfo;
 
   PopulateZookeeperWithNamespace(NamespaceInfo ti) {
     this.namespaceInfo = ti;
@@ -53,13 +50,11 @@ class PopulateZookeeperWithNamespace extends ManagerRepo {
       Utils.checkNamespaceDoesNotExist(manager.getContext(), namespaceInfo.namespaceName,
           namespaceInfo.namespaceId, TableOperation.CREATE);
 
-      TableManager.prepareNewNamespaceState(manager.getContext().getZooReaderWriter(),
-          manager.getInstanceID(), namespaceInfo.namespaceId, namespaceInfo.namespaceName,
-          NodeExistsPolicy.OVERWRITE);
+      TableManager.prepareNewNamespaceState(manager.getContext(), namespaceInfo.namespaceId,
+          namespaceInfo.namespaceName, NodeExistsPolicy.OVERWRITE);
 
-      for (Entry<String,String> entry : namespaceInfo.props.entrySet())
-        NamespacePropUtil.setNamespaceProperty(manager.getContext(), namespaceInfo.namespaceId,
-            entry.getKey(), entry.getValue());
+      manager.getContext().namespacePropUtil().setProperties(namespaceInfo.namespaceId,
+          namespaceInfo.props);
 
       manager.getContext().clearTableListCache();
 
diff --git a/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/tableImport/ImportPopulateZookeeper.java b/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/tableImport/ImportPopulateZookeeper.java
index 4a6c78999d..53c0cc7b9d 100644
--- a/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/tableImport/ImportPopulateZookeeper.java
+++ b/server/manager/src/main/java/org/apache/accumulo/manager/tableOps/tableImport/ImportPopulateZookeeper.java
@@ -20,7 +20,6 @@ package org.apache.accumulo.manager.tableOps.tableImport;
 
 import java.io.IOException;
 import java.util.Map;
-import java.util.Map.Entry;
 
 import org.apache.accumulo.core.clientImpl.AcceptableThriftTableOperationException;
 import org.apache.accumulo.core.clientImpl.Namespaces;
@@ -34,7 +33,6 @@ import org.apache.accumulo.manager.Manager;
 import org.apache.accumulo.manager.tableOps.ManagerRepo;
 import org.apache.accumulo.manager.tableOps.Utils;
 import org.apache.accumulo.server.fs.VolumeManager;
-import org.apache.accumulo.server.util.TablePropUtil;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
 
@@ -88,13 +86,14 @@ class ImportPopulateZookeeper extends ManagerRepo {
     }
 
     VolumeManager volMan = env.getVolumeManager();
-    for (Entry<String,String> entry : getExportedProps(volMan).entrySet())
-      if (!TablePropUtil.setTableProperty(env.getContext(), tableInfo.tableId, entry.getKey(),
-          entry.getValue())) {
-        throw new AcceptableThriftTableOperationException(tableInfo.tableId.canonical(),
-            tableInfo.tableName, TableOperation.IMPORT, TableOperationExceptionType.OTHER,
-            "Invalid table property " + entry.getKey());
-      }
+
+    try {
+      env.getContext().tablePropUtil().setProperties(tableInfo.tableId, getExportedProps(volMan));
+    } catch (IllegalStateException ex) {
+      throw new AcceptableThriftTableOperationException(tableInfo.tableId.canonical(),
+          tableInfo.tableName, TableOperation.IMPORT, TableOperationExceptionType.OTHER,
+          "failed to set table properties");
+    }
 
     return new CreateImportDir(tableInfo);
   }
diff --git a/server/manager/src/main/java/org/apache/accumulo/manager/upgrade/Upgrader9to10.java b/server/manager/src/main/java/org/apache/accumulo/manager/upgrade/Upgrader9to10.java
index 20136e7fad..62a2cecc61 100644
--- a/server/manager/src/main/java/org/apache/accumulo/manager/upgrade/Upgrader9to10.java
+++ b/server/manager/src/main/java/org/apache/accumulo/manager/upgrade/Upgrader9to10.java
@@ -43,8 +43,6 @@ import org.apache.accumulo.core.client.MutationsRejectedException;
 import org.apache.accumulo.core.client.Scanner;
 import org.apache.accumulo.core.client.TableNotFoundException;
 import org.apache.accumulo.core.client.admin.TimeType;
-import org.apache.accumulo.core.conf.ConfigurationCopy;
-import org.apache.accumulo.core.conf.DeprecatedPropertyUtil;
 import org.apache.accumulo.core.conf.Property;
 import org.apache.accumulo.core.data.Key;
 import org.apache.accumulo.core.data.Mutation;
@@ -74,13 +72,11 @@ import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
 import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeExistsPolicy;
 import org.apache.accumulo.fate.zookeeper.ZooUtil.NodeMissingPolicy;
 import org.apache.accumulo.server.ServerContext;
-import org.apache.accumulo.server.conf.ZooConfiguration;
+import org.apache.accumulo.server.conf.util.ConfigPropertyUpgrader;
 import org.apache.accumulo.server.fs.VolumeManager;
 import org.apache.accumulo.server.gc.GcVolumeUtil;
 import org.apache.accumulo.server.metadata.RootGcCandidates;
 import org.apache.accumulo.server.metadata.TabletMutatorBase;
-import org.apache.accumulo.server.util.SystemPropUtil;
-import org.apache.accumulo.server.util.TablePropUtil;
 import org.apache.hadoop.fs.FileStatus;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
@@ -123,9 +119,9 @@ public class Upgrader9to10 implements Upgrader {
 
   @Override
   public void upgradeZookeeper(ServerContext context) {
+    upgradePropertyStorage(context);
     setMetaTableProps(context);
     upgradeRootTabletMetadata(context);
-    renameOldMasterPropsinZK(context);
     createExternalCompactionNodes(context);
     // special case where old files need to be deleted
     dropSortedMapWALFiles(context);
@@ -145,24 +141,33 @@ public class Upgrader9to10 implements Upgrader {
     upgradeFileDeletes(context, Ample.DataLevel.USER);
   }
 
+  /**
+   * Convert system properties (if necessary) and all table properties to a single node
+   */
+  private void upgradePropertyStorage(ServerContext context) {
+    log.info("Starting property conversion");
+    ConfigPropertyUpgrader configUpgrader = new ConfigPropertyUpgrader();
+    configUpgrader.doUpgrade(context.getInstanceID(), context.getZooReaderWriter());
+    log.info("Completed property conversion");
+  }
+
   /**
    * Setup properties for External compactions.
    */
   private void setMetaTableProps(ServerContext context) {
     try {
-      TablePropUtil.setTableProperty(context, RootTable.ID,
-          Property.TABLE_COMPACTION_DISPATCHER.getKey(),
-          SimpleCompactionDispatcher.class.getName());
-      TablePropUtil.setTableProperty(context, RootTable.ID,
-          Property.TABLE_COMPACTION_DISPATCHER_OPTS.getKey() + "service", "root");
-
-      TablePropUtil.setTableProperty(context, MetadataTable.ID,
-          Property.TABLE_COMPACTION_DISPATCHER.getKey(),
-          SimpleCompactionDispatcher.class.getName());
-      TablePropUtil.setTableProperty(context, MetadataTable.ID,
-          Property.TABLE_COMPACTION_DISPATCHER_OPTS.getKey() + "service", "meta");
-    } catch (KeeperException | InterruptedException e) {
-      throw new RuntimeException("Unable to set system table properties", e);
+      // root compaction props
+      context.tablePropUtil().setProperties(RootTable.ID,
+          Map.of(Property.TABLE_COMPACTION_DISPATCHER.getKey(),
+              SimpleCompactionDispatcher.class.getName(),
+              Property.TABLE_COMPACTION_DISPATCHER_OPTS.getKey() + "service", "root"));
+      // metadata compaction props
+      context.tablePropUtil().setProperties(MetadataTable.ID,
+          Map.of(Property.TABLE_COMPACTION_DISPATCHER.getKey(),
+              SimpleCompactionDispatcher.class.getName(),
+              Property.TABLE_COMPACTION_DISPATCHER_OPTS.getKey() + "service", "meta"));
+    } catch (IllegalStateException ex) {
+      throw new RuntimeException("Unable to set system table properties", ex);
     }
   }
 
@@ -242,31 +247,9 @@ public class Upgrader9to10 implements Upgrader {
     delete(context, ZROOT_TABLET_PATH);
   }
 
-  @SuppressWarnings("deprecation")
-  private void renameOldMasterPropsinZK(ServerContext context) {
-    // Rename all of the properties only set in ZooKeeper that start with "master." to rename and
-    // store them starting with "manager." instead.
-    var zooConfiguration =
-        new ZooConfiguration(context, context.getZooCache(), new ConfigurationCopy());
-    zooConfiguration.getAllPropertiesWithPrefix(Property.MASTER_PREFIX)
-        .forEach((original, value) -> {
-          DeprecatedPropertyUtil.getReplacementName(original, (log, replacement) -> {
-            log.info("Automatically renaming deprecated property '{}' with its replacement '{}'"
-                + " in ZooKeeper on upgrade.", original, replacement);
-            try {
-              // Set the property under the new name
-              SystemPropUtil.setSystemProperty(context, replacement, value);
-              SystemPropUtil.removePropWithoutDeprecationWarning(context, original);
-            } catch (KeeperException | InterruptedException e) {
-              throw new RuntimeException("Unable to upgrade system properties", e);
-            }
-          });
-        });
-  }
-
   private static class UpgradeMutator extends TabletMutatorBase {
 
-    private ServerContext context;
+    private final ServerContext context;
 
     UpgradeMutator(ServerContext context) {
       super(context, RootTable.EXTENT);
@@ -481,11 +464,10 @@ public class Upgrader9to10 implements Upgrader {
   public void upgradeFileDeletes(ServerContext context, Ample.DataLevel level) {
 
     String tableName = level.metaTable();
-    AccumuloClient c = context;
     Ample ample = context.getAmple();
 
     // find all deletes
-    try (BatchWriter writer = c.createBatchWriter(tableName)) {
+    try (BatchWriter writer = context.createBatchWriter(tableName)) {
       log.info("looking for candidates in table {}", tableName);
       Iterator<String> oldCandidates = getOldCandidates(context, tableName);
       String upgradeProp =
@@ -580,10 +562,9 @@ public class Upgrader9to10 implements Upgrader {
    */
   public void upgradeDirColumns(ServerContext context, Ample.DataLevel level) {
     String tableName = level.metaTable();
-    AccumuloClient c = context;
 
-    try (Scanner scanner = c.createScanner(tableName, Authorizations.EMPTY);
-        BatchWriter writer = c.createBatchWriter(tableName)) {
+    try (Scanner scanner = context.createScanner(tableName, Authorizations.EMPTY);
+        BatchWriter writer = context.createBatchWriter(tableName)) {
       DIRECTORY_COLUMN.fetch(scanner);
 
       for (Entry<Key,Value> entry : scanner) {
@@ -611,13 +592,12 @@ public class Upgrader9to10 implements Upgrader {
    */
   public static void upgradeRelativePaths(ServerContext context, Ample.DataLevel level) {
     String tableName = level.metaTable();
-    AccumuloClient c = context;
     VolumeManager fs = context.getVolumeManager();
     String upgradeProp = context.getConfiguration().get(Property.INSTANCE_VOLUMES_UPGRADE_RELATIVE);
 
     // first pass check for relative paths - if any, check existence of the file path
     // constructed from the upgrade property + relative path
-    if (checkForRelativePaths(c, fs, tableName, upgradeProp)) {
+    if (checkForRelativePaths(context, fs, tableName, upgradeProp)) {
       log.info("Relative Tablet File paths exist in {}, replacing with absolute using {}",
           tableName, upgradeProp);
     } else {
@@ -626,7 +606,7 @@ public class Upgrader9to10 implements Upgrader {
     }
 
     // second pass, create atomic mutations to replace the relative path
-    replaceRelativePaths(c, fs, tableName, upgradeProp);
+    replaceRelativePaths(context, fs, tableName, upgradeProp);
   }
 
   /**
diff --git a/server/tserver/src/main/java/org/apache/accumulo/tserver/TabletServerResourceManager.java b/server/tserver/src/main/java/org/apache/accumulo/tserver/TabletServerResourceManager.java
index 74ef81c7ed..6cc29716c8 100644
--- a/server/tserver/src/main/java/org/apache/accumulo/tserver/TabletServerResourceManager.java
+++ b/server/tserver/src/main/java/org/apache/accumulo/tserver/TabletServerResourceManager.java
@@ -252,7 +252,6 @@ public class TabletServerResourceManager {
   public TabletServerResourceManager(ServerContext context) {
     this.context = context;
     final AccumuloConfiguration acuConf = context.getConfiguration();
-
     long maxMemory = acuConf.getAsBytes(Property.TSERV_MAXMEM);
     boolean usingNativeMap = acuConf.getBoolean(Property.TSERV_NATIVEMAP_ENABLED);
     if (usingNativeMap) {
diff --git a/test/pom.xml b/test/pom.xml
index 0cbcfb085f..0ee138e467 100644
--- a/test/pom.xml
+++ b/test/pom.xml
@@ -37,6 +37,10 @@
       <groupId>com.beust</groupId>
       <artifactId>jcommander</artifactId>
     </dependency>
+    <dependency>
+      <groupId>com.github.ben-manes.caffeine</groupId>
+      <artifactId>caffeine</artifactId>
+    </dependency>
     <dependency>
       <groupId>com.google.auto.service</groupId>
       <artifactId>auto-service</artifactId>
diff --git a/test/src/main/java/org/apache/accumulo/test/TableConfigurationUpdateIT.java b/test/src/main/java/org/apache/accumulo/test/TableConfigurationUpdateIT.java
index f911774e17..099cf78450 100644
--- a/test/src/main/java/org/apache/accumulo/test/TableConfigurationUpdateIT.java
+++ b/test/src/main/java/org/apache/accumulo/test/TableConfigurationUpdateIT.java
@@ -61,8 +61,8 @@ public class TableConfigurationUpdateIT extends AccumuloClusterHarness {
       String table = getUniqueNames(1)[0];
       client.tableOperations().create(table);
 
-      final NamespaceConfiguration defaultConf = new NamespaceConfiguration(Namespace.DEFAULT.id(),
-          context, DefaultConfiguration.getInstance());
+      final NamespaceConfiguration defaultConf = new NamespaceConfiguration(context,
+          Namespace.DEFAULT.id(), DefaultConfiguration.getInstance());
 
       // Cache invalidates 25% of the time
       int randomMax = 4;
@@ -70,8 +70,8 @@ public class TableConfigurationUpdateIT extends AccumuloClusterHarness {
       int numThreads = 2;
       // Number of iterations per thread
       int iterations = 100000;
-      AccumuloConfiguration tableConf =
-          new TableConfiguration(context, TableId.of(table), defaultConf);
+      TableId tid = TableId.of(client.tableOperations().tableIdMap().get(table));
+      AccumuloConfiguration tableConf = new TableConfiguration(context, tid, defaultConf);
 
       long start = System.currentTimeMillis();
       ExecutorService svc = Executors.newFixedThreadPool(numThreads);
diff --git a/test/src/main/java/org/apache/accumulo/test/conf/PropStoreConfigIT.java b/test/src/main/java/org/apache/accumulo/test/conf/PropStoreConfigIT.java
new file mode 100644
index 0000000000..190b11a074
--- /dev/null
+++ b/test/src/main/java/org/apache/accumulo/test/conf/PropStoreConfigIT.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.test.conf;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.accumulo.harness.AccumuloITBase.MINI_CLUSTER_ONLY;
+import static org.apache.accumulo.harness.AccumuloITBase.SUNNY_DAY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Map;
+
+import org.apache.accumulo.core.client.Accumulo;
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.harness.AccumuloClusterHarness;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Tag(MINI_CLUSTER_ONLY)
+@Tag(SUNNY_DAY)
+public class PropStoreConfigIT extends AccumuloClusterHarness {
+
+  private static final Logger log = LoggerFactory.getLogger(PropStoreConfigIT.class);
+
+  @Test
+  public void setTablePropTest() throws Exception {
+    String table = getUniqueNames(1)[0];
+
+    try (var client = Accumulo.newClient().from(getClientProps()).build()) {
+
+      client.tableOperations().create(table);
+
+      log.info("Tables: {}", client.tableOperations().list());
+
+      client.instanceOperations().setProperty(Property.TABLE_BLOOM_ENABLED.getKey(), "true");
+      client.tableOperations().setProperty(table, Property.TABLE_BLOOM_ENABLED.getKey(), "true");
+
+      Thread.sleep(SECONDS.toMillis(3L));
+
+      var props = client.tableOperations().getProperties(table);
+      log.info("Props: {}", props);
+      for (Map.Entry<String,String> e : props) {
+        if (e.getKey().contains("table.bloom.enabled")) {
+          log.info("after bloom property: {}={}", e.getKey(), e.getValue());
+          assertEquals("true", e.getValue());
+        }
+      }
+    }
+  }
+}
diff --git a/test/src/main/java/org/apache/accumulo/test/conf/store/PropCacheCaffeineImplZkIT.java b/test/src/main/java/org/apache/accumulo/test/conf/store/PropCacheCaffeineImplZkIT.java
new file mode 100644
index 0000000000..2f0b9034dd
--- /dev/null
+++ b/test/src/main/java/org/apache/accumulo/test/conf/store/PropCacheCaffeineImplZkIT.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.test.conf.store;
+
+import static org.apache.accumulo.core.conf.Property.GC_PORT;
+import static org.apache.accumulo.core.conf.Property.MANAGER_CLIENTPORT;
+import static org.apache.accumulo.core.conf.Property.TSERV_CLIENTPORT;
+import static org.apache.accumulo.core.conf.Property.TSERV_NATIVEMAP_ENABLED;
+import static org.apache.accumulo.core.conf.Property.TSERV_SCAN_MAX_OPENFILES;
+import static org.apache.accumulo.harness.AccumuloITBase.ZOOKEEPER_TESTING_SERVER;
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.accumulo.core.Constants;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.core.metrics.MetricsUtil;
+import org.apache.accumulo.fate.zookeeper.ZooReaderWriter;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.PropCacheCaffeineImpl;
+import org.apache.accumulo.server.conf.store.impl.PropStoreMetrics;
+import org.apache.accumulo.server.conf.store.impl.PropStoreWatcher;
+import org.apache.accumulo.server.conf.store.impl.ReadyMonitor;
+import org.apache.accumulo.server.conf.store.impl.ZooPropLoader;
+import org.apache.accumulo.test.zookeeper.ZooKeeperTestingServer;
+import org.apache.zookeeper.CreateMode;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.ZKUtil;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.ZooKeeper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Tag(ZOOKEEPER_TESTING_SERVER)
+public class PropCacheCaffeineImplZkIT {
+
+  private static final Logger log = LoggerFactory.getLogger(PropCacheCaffeineImplZkIT.class);
+  private static final InstanceId INSTANCE_ID = InstanceId.of(UUID.randomUUID());
+
+  private static ZooKeeperTestingServer testZk = null;
+  private static ZooReaderWriter zrw;
+  private static ZooKeeper zooKeeper;
+
+  private final TableId tIdA = TableId.of("A");
+  private final TableId tIdB = TableId.of("B");
+  private final PropStoreMetrics cacheMetrics = new PropStoreMetrics();
+  private static ServerContext context;
+
+  @TempDir
+  private static File tempDir;
+
+  @BeforeAll
+  public static void setupZk() {
+    // using default zookeeper port - we don't have a full configuration
+    testZk = new ZooKeeperTestingServer(tempDir);
+    zooKeeper = testZk.getZooKeeper();
+
+    zrw = testZk.getZooReaderWriter();
+    context = createNiceMock(ServerContext.class);
+    expect(context.getInstanceID()).andReturn(INSTANCE_ID).anyTimes();
+    expect(context.getZooReaderWriter()).andReturn(zrw).anyTimes();
+
+    replay(context);
+  }
+
+  @AfterAll
+  public static void shutdownZK() throws Exception {
+    verify(context);
+    testZk.close();
+  }
+
+  @BeforeEach
+  public void setupZnodes() {
+    testZk.initPaths(ZooUtil.getRoot(INSTANCE_ID) + Constants.ZCONFIG);
+    try {
+      zooKeeper.create(ZooUtil.getRoot(INSTANCE_ID) + Constants.ZTABLES, new byte[0],
+          ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
+      zooKeeper.create(ZooUtil.getRoot(INSTANCE_ID) + Constants.ZTABLES + "/" + tIdA.canonical(),
+          new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
+      zooKeeper.create(
+          ZooUtil.getRoot(INSTANCE_ID) + Constants.ZTABLES + "/" + tIdA.canonical() + "/conf",
+          new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
+
+      zooKeeper.create(ZooUtil.getRoot(INSTANCE_ID) + Constants.ZTABLES + "/" + tIdB.canonical(),
+          new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
+      zooKeeper.create(
+          ZooUtil.getRoot(INSTANCE_ID) + Constants.ZTABLES + "/" + tIdB.canonical() + "/conf",
+          new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
+
+    } catch (KeeperException ex) {
+      log.trace("Issue during zk initialization, skipping", ex);
+    } catch (InterruptedException ex) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("Interrupted during zookeeper path initialization", ex);
+    }
+  }
+
+  @AfterEach
+  public void cleanupZnodes() {
+    try {
+      ZKUtil.deleteRecursive(zooKeeper, "/accumulo");
+    } catch (KeeperException | InterruptedException ex) {
+      throw new IllegalStateException("Failed to clean-up test zooKeeper nodes.", ex);
+    }
+  }
+
+  @Test
+  public void init() throws Exception {
+    Map<String,String> props = new HashMap<>();
+    props.put(TSERV_CLIENTPORT.getKey(), "1234");
+    props.put(TSERV_NATIVEMAP_ENABLED.getKey(), "false");
+    props.put(TSERV_SCAN_MAX_OPENFILES.getKey(), "2345");
+    props.put(MANAGER_CLIENTPORT.getKey(), "3456");
+    props.put(GC_PORT.getKey(), "4567");
+    VersionedProperties vProps = new VersionedProperties(props);
+
+    // directly create prop node - simulate existing properties.
+    var propCacheKey = TablePropKey.of(INSTANCE_ID, tIdA);
+    var created = zrw.putPersistentData(propCacheKey.getPath(),
+        VersionedPropCodec.getDefault().toBytes(vProps), ZooUtil.NodeExistsPolicy.FAIL);
+
+    assertTrue(created, "expected properties to be created");
+
+    ReadyMonitor readyMonitor = new ReadyMonitor("test", zooKeeper.getSessionTimeout());
+
+    PropStoreWatcher propStoreWatcher = new PropStoreWatcher(readyMonitor);
+
+    MetricsUtil.initializeProducers(cacheMetrics);
+
+    ZooPropLoader propLoader =
+        new ZooPropLoader(zrw, VersionedPropCodec.getDefault(), propStoreWatcher, cacheMetrics);
+    PropCacheCaffeineImpl cache =
+        new PropCacheCaffeineImpl.Builder(propLoader, cacheMetrics).build();
+
+    VersionedProperties readProps = cache.get(propCacheKey);
+
+    if (readProps == null) {
+      fail("Received null for versioned properties");
+    } else {
+      log.debug("Props read from cache: {}", readProps.print(true));
+    }
+
+  }
+}
diff --git a/test/src/main/java/org/apache/accumulo/test/conf/store/PropStoreZooKeeperIT.java b/test/src/main/java/org/apache/accumulo/test/conf/store/PropStoreZooKeeperIT.java
new file mode 100644
index 0000000000..74518c5a48
--- /dev/null
+++ b/test/src/main/java/org/apache/accumulo/test/conf/store/PropStoreZooKeeperIT.java
@@ -0,0 +1,416 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.accumulo.test.conf.store;
+
+import static org.apache.accumulo.harness.AccumuloITBase.ZOOKEEPER_TESTING_SERVER;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.accumulo.core.Constants;
+import org.apache.accumulo.core.conf.Property;
+import org.apache.accumulo.core.data.InstanceId;
+import org.apache.accumulo.core.data.TableId;
+import org.apache.accumulo.fate.zookeeper.ZooUtil;
+import org.apache.accumulo.server.ServerContext;
+import org.apache.accumulo.server.conf.codec.VersionedPropCodec;
+import org.apache.accumulo.server.conf.codec.VersionedProperties;
+import org.apache.accumulo.server.conf.store.PropCacheKey;
+import org.apache.accumulo.server.conf.store.PropChangeListener;
+import org.apache.accumulo.server.conf.store.PropStore;
+import org.apache.accumulo.server.conf.store.TablePropKey;
+import org.apache.accumulo.server.conf.store.impl.ZooPropStore;
+import org.apache.accumulo.test.zookeeper.ZooKeeperTestingServer;
+import org.apache.zookeeper.CreateMode;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.ZKUtil;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.ZooKeeper;
+import org.apache.zookeeper.data.Stat;
+import org.easymock.EasyMock;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Tag(ZOOKEEPER_TESTING_SERVER)
+public class PropStoreZooKeeperIT {
+
+  private static final Logger log = LoggerFactory.getLogger(PropStoreZooKeeperIT.class);
+  private static final VersionedPropCodec propCodec = VersionedPropCodec.getDefault();
+  private static ZooKeeperTestingServer testZk = null;
+  private static ZooKeeper zooKeeper;
+  private ServerContext context;
+  private InstanceId instanceId = null;
+  private PropStore propStore = null;
+  private final TableId tIdA = TableId.of("A");
+  private final TableId tIdB = TableId.of("B");
+
+  @TempDir
+  private static File tempDir;
+
... 1837 lines suppressed ...