001package arez.persist.runtime; 002 003import arez.Disposable; 004import arez.SafeProcedure; 005import java.util.HashMap; 006import java.util.Map; 007import java.util.Objects; 008import javax.annotation.Nonnull; 009import javax.annotation.Nullable; 010import static org.realityforge.braincheck.Guards.*; 011 012/** 013 * The store that contains a cached copy of any state persisted. 014 * The store is also responsible for committing state to a storage service when the state has changed. 015 * The commit is typically an asynchronous process that occurs in another process so as not to block the 016 * ui thread. 017 */ 018public final class Store 019{ 020 /** 021 * In-memory cache of configuration data. 022 */ 023 @Nonnull 024 private final Map<Scope, Map<String, Map<String, StorageService.Entry>>> _config = new HashMap<>(); 025 /** 026 * Has the config data been committed to the backend storage? 027 */ 028 private boolean _committed = true; 029 /** 030 * The service that stores the state. 031 */ 032 @Nonnull 033 private final StorageService _storageService; 034 /** 035 * Cache for action that performs commit. 036 */ 037 @Nonnull 038 private final SafeProcedure _commitTriggerAction = this::commit; 039 /** 040 * Flag indicating this store has been disposed. 041 */ 042 private boolean _disposed; 043 044 /** 045 * Create the store. 046 * 047 * @param storageService the underlying storage service that manages the state. 048 */ 049 Store( @Nonnull final StorageService storageService ) 050 { 051 _storageService = Objects.requireNonNull( storageService ); 052 } 053 054 /** 055 * Clear the state in store and reload it from the backend service. 056 */ 057 void restore() 058 { 059 _config.clear(); 060 _storageService.restore( _config ); 061 } 062 063 /** 064 * Release all state stored under scope and any nested scope. 065 * If any state is actually removed, then a commit is scheduled. 066 * 067 * @param scope the scope. 068 */ 069 void releaseScope( @Nonnull final Scope scope ) 070 { 071 scope.getNestedScopes().forEach( this::releaseScope ); 072 if ( null != _config.remove( scope ) ) 073 { 074 scheduleCommit(); 075 } 076 } 077 078 /** 079 * Save the state for a single component to the store. 080 * If the state value is empty this operation is effectively a remove. If any changes are made to 081 * the store as a result of this operation then a commit is scheduled. 082 * 083 * @param scope the scope in which the state is saved. 084 * @param type the string that identifies the type of component. 085 * @param id a string representation of the component id. 086 * @param state the state to store. 087 * @param converter the converter to use to encode state for storage. 088 */ 089 public void save( @Nonnull final Scope scope, 090 @Nonnull final String type, 091 @Nonnull final String id, 092 @Nonnull final Map<String, Object> state, 093 @Nonnull final TypeConverter converter ) 094 { 095 if ( ArezPersist.shouldCheckApiInvariants() ) 096 { 097 apiInvariant( () -> !isDisposed(), () -> "Store.save() invoked after the store has been disposed" ); 098 apiInvariant( () -> Disposable.isNotDisposed( scope ), 099 () -> "Store.save() passed a disposed scope named '" + scope.getName() + "'" ); 100 } 101 102 if ( state.isEmpty() ) 103 { 104 remove( scope, type, id ); 105 } 106 else 107 { 108 // Initial experiments converted state in a separate idle callback but the overhead of 109 // asynchronous queuing and callback when the encoded types are primitive and not rich types 110 // requiring converters did not seem worth it 111 _config 112 .computeIfAbsent( scope, t -> new HashMap<>() ) 113 .computeIfAbsent( type, t -> new HashMap<>() ) 114 .put( id, new StorageService.Entry( state, _storageService.encodeState( state, converter ) ) ); 115 scheduleCommit(); 116 } 117 } 118 119 /** 120 * Remove the state for a single component from the store. 121 * If the state does not exist then this is a noop, otherwise a commit is scheduled. 122 * 123 * @param scope the scope in which the state is saved. 124 * @param type the string that identifies the type of component. 125 * @param id a string representation of the component id. 126 */ 127 public void remove( @Nonnull final Scope scope, @Nonnull final String type, @Nonnull final String id ) 128 { 129 if ( ArezPersist.shouldCheckApiInvariants() ) 130 { 131 apiInvariant( () -> !isDisposed(), () -> "Store.remove() invoked after the store has been disposed" ); 132 apiInvariant( () -> Disposable.isNotDisposed( scope ), 133 () -> "Store.remove() passed a disposed scope named '" + scope.getName() + "'" ); 134 } 135 final Map<String, Map<String, StorageService.Entry>> scopeMap = _config.get( scope ); 136 final Map<String, StorageService.Entry> typeMap = null != scopeMap ? scopeMap.get( type ) : null; 137 if ( null != typeMap && null != typeMap.remove( id ) ) 138 { 139 scheduleCommit(); 140 } 141 } 142 143 /** 144 * Retrieve the state for a single component from the store. 145 * If the state does not exist then a null is returned. 146 * 147 * @param scope the scope in which the state is saved. 148 * @param type the string that identifies the type of component. 149 * @param id a string representation of the component id. 150 * @param converter the converter to use to decode state from storage. 151 * @return the component state if it exists, else null. 152 */ 153 @Nullable 154 public Map<String, Object> get( @Nonnull final Scope scope, 155 @Nonnull final String type, 156 @Nonnull final String id, 157 @Nonnull final TypeConverter converter ) 158 { 159 if ( ArezPersist.shouldCheckApiInvariants() ) 160 { 161 apiInvariant( () -> !isDisposed(), () -> "Store.get() invoked after the store has been disposed" ); 162 apiInvariant( () -> Disposable.isNotDisposed( scope ), 163 () -> "Store.get() passed a disposed scope named '" + scope.getName() + "'" ); 164 } 165 final Map<String, Map<String, StorageService.Entry>> scopeMap = _config.get( scope ); 166 final Map<String, StorageService.Entry> typeMap = null != scopeMap ? scopeMap.get( type ) : null; 167 if ( null != typeMap ) 168 { 169 final StorageService.Entry entry = typeMap.get( id ); 170 if ( null == entry ) 171 { 172 return null; 173 } 174 else 175 { 176 final Map<String, Object> data = entry.getData(); 177 if ( null == data ) 178 { 179 final Map<String, Object> decoded = _storageService.decodeState( entry.getEncoded(), converter ); 180 entry.setData( decoded ); 181 return decoded; 182 } 183 else 184 { 185 return data; 186 } 187 } 188 } 189 else 190 { 191 return null; 192 } 193 } 194 195 /** 196 * Return true if this service has been disposed. 197 * A disposed service should not be interacted with. 198 * 199 * @return true if this service has been disposed, false otherwise. 200 * @see #dispose() 201 */ 202 public boolean isDisposed() 203 { 204 return _disposed; 205 } 206 207 void dispose() 208 { 209 assert !_disposed; 210 _disposed = true; 211 _storageService.dispose(); 212 } 213 214 /** 215 * Schedule a commit if one is not already pending. 216 */ 217 private void scheduleCommit() 218 { 219 if ( _committed ) 220 { 221 _committed = false; 222 _storageService.scheduleCommit( _commitTriggerAction ); 223 } 224 } 225 226 /** 227 * Commit state to storage service. 228 */ 229 private void commit() 230 { 231 if ( !_committed ) 232 { 233 _storageService.commit( _config ); 234 _committed = true; 235 } 236 } 237 238 @Nonnull 239 Map<Scope, Map<String, Map<String, StorageService.Entry>>> getConfig() 240 { 241 return _config; 242 } 243}