-
Notifications
You must be signed in to change notification settings - Fork 1
Cache implementation #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aashishpatil-g
wants to merge
40
commits into
main
Choose a base branch
from
ap/coreSdkCacheV1
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 39 commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
7c92921
Initial Stub Classes
aashishpatil-g f56a8cc
Wire up Initialization of CacheProvider
aashishpatil-g f655a15
query level caching
aashishpatil-g 90c7ae1
Cache Normalization Implementation
aashishpatil-g 5174e03
Persistent Provider
aashishpatil-g ad71e3c
Add last_accessed field
aashishpatil-g c0a024b
Uber Cache object, support Auth uid scope and Refactor classes
aashishpatil-g 0bcdf09
Implement operationId on QueryRef
aashishpatil-g ad749e7
Fix sha256 formatter.
aashishpatil-g 35dc280
Accumulate Impacted Refs and Reload Local
aashishpatil-g 6cd60ef
Update construction of cacheIdentifier to include connector and locat…
aashishpatil-g 824a41e
Use inmemory SQLite for Ephemeral cache provider
aashishpatil-g f503f5e
Minor updates to API
aashishpatil-g e57bbb4
Refactor BackingDataObject, STubDataObject names to EntityDataObject …
aashishpatil-g 353caf5
DispatchQueue for EntityDataObject access
aashishpatil-g 78599a3
Externalize table and column name strings into constants
aashishpatil-g 250e905
API Review feedback
aashishpatil-g f1aa717
Code formatting updates
aashishpatil-g d9b4807
Refactor name of OperationResultSource
aashishpatil-g 1c666ec
API feedback - rename maxSize => maxSizeBytes
aashishpatil-g 8ea6762
API Council Review Feedback
aashishpatil-g a807d9f
Move ServerResponse
aashishpatil-g 336e41b
Update cache type to match API review feedback
aashishpatil-g 37493c4
Cleanup and minor fixes to docs
aashishpatil-g dd8569c
Update globalID value
aashishpatil-g fad7e2f
Fix format
aashishpatil-g a62c16b
Fix copyright notices
aashishpatil-g 30940a6
Fix @available
aashishpatil-g 5482987
fix integration tests
aashishpatil-g 1a9aeb9
Gemini Review part 1
aashishpatil-g 12da757
formatting fixes
aashishpatil-g 334e710
Convert sync queue calls to actor
aashishpatil-g b1569e8
comments
aashishpatil-g 517fdcd
Nick feedback
aashishpatil-g b9265dc
Remove unnecessary custom codable in EDO
aashishpatil-g 6689c9c
Convert data connect to a weak reference in cache
aashishpatil-g 2b90b52
formatting fixes
aashishpatil-g edfcd83
Doc comments for Settings
aashishpatil-g 2572377
Incorporate maxAge
aashishpatil-g ba3b894
Nick feedback. Update doc comments on CacheSettings.maxAge
aashishpatil-g File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| // Copyright 2025 Google LLC | ||
| // | ||
| // Licensed 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. | ||
|
|
||
| import FirebaseAuth | ||
|
|
||
| // FDC field name in server response that identifies a GlobalID | ||
| let GlobalIDKey: String = "_id" | ||
|
|
||
| // Client cache that internally uses a CacheProvider to store content. | ||
| @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) | ||
| actor Cache { | ||
| let config: CacheSettings | ||
| weak var dataConnect: DataConnect? | ||
|
|
||
| private var cacheProvider: CacheProvider? | ||
|
|
||
| // holding it to avoid dealloc | ||
| private var authChangeListenerProtocol: NSObjectProtocol? | ||
|
|
||
| init(config: CacheSettings, dataConnect: DataConnect) { | ||
| self.config = config | ||
| self.dataConnect = dataConnect | ||
|
|
||
| // this is a potential race since update or get could get scheduled before initialize | ||
| // workarounds are complex since caller DataConnect APIs aren't async | ||
| Task { | ||
| await initializeCacheProvider() | ||
| await setupChangeListeners() | ||
| } | ||
| } | ||
|
|
||
| private func initializeCacheProvider() { | ||
| let identifier = contructCacheIdentifier() | ||
|
|
||
| guard identifier.isEmpty == false else { | ||
| DataConnectLogger.error("CacheIdentifier is empty. Caching is disabled") | ||
| return | ||
| } | ||
|
|
||
| // Create a cacheProvider if - | ||
| // we don't have an existing cacheProvider | ||
| // we have one but its identifier is different than new one (e.g. auth uid changed) | ||
| if cacheProvider != nil, cacheProvider?.cacheIdentifier == identifier { | ||
| return | ||
| } | ||
|
|
||
| do { | ||
| switch config.storage { | ||
| case .memory: | ||
| cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) | ||
| case .persistent: | ||
| cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false) | ||
| } | ||
| } catch { | ||
| DataConnectLogger.error("Unable to initialize Persistent provider \(error)") | ||
| } | ||
| } | ||
|
|
||
| private func setupChangeListeners() { | ||
| guard let dataConnect else { | ||
| DataConnectLogger.error("Unable to setup auth change listeners since DataConnect is nil") | ||
| return | ||
| } | ||
|
|
||
| authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in | ||
| self.initializeCacheProvider() | ||
| } | ||
| } | ||
|
|
||
| // Create an identifier for the cache that the Provider will use for cache scoping | ||
| private func contructCacheIdentifier() -> String { | ||
| guard let dataConnect else { | ||
| DataConnectLogger.error("Unable to construct a cache identifier since DataConnect is nil") | ||
| return "" | ||
| } | ||
|
|
||
| let identifier = | ||
| "\(config.storage)-\(dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" | ||
| let encoded = identifier.sha256 | ||
| DataConnectLogger.debug("Created Encoded Cache Identifier \(encoded) for \(identifier)") | ||
| return encoded | ||
| } | ||
|
|
||
| func resultTree(queryId: String) -> ResultTree? { | ||
| // result trees are stored dehydrated in the cache | ||
| // retrieve cache, hydrate it and then return | ||
| guard let cacheProvider else { | ||
| DataConnectLogger.error("CacheProvider is nil in the Cache") | ||
| return nil | ||
| } | ||
|
|
||
| guard let dehydratedTree = cacheProvider.resultTree(queryId: queryId) else { | ||
| return nil | ||
| } | ||
|
|
||
| do { | ||
| let resultsProcessor = ResultTreeProcessor() | ||
| let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults( | ||
| dehydratedTree.data, | ||
| cacheProvider: cacheProvider | ||
| ) | ||
|
|
||
| let hydratedTree = ResultTree( | ||
| data: hydratedResults, | ||
| cachedAt: dehydratedTree.cachedAt, | ||
| lastAccessed: dehydratedTree.lastAccessed, | ||
| rootObject: rootObj | ||
| ) | ||
|
|
||
| return hydratedTree | ||
| } catch { | ||
| DataConnectLogger.warning("Error decoding result tree \(error)") | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func update(queryId: String, response: ServerResponse, requestor: (any QueryRefInternal)? = nil) { | ||
| // server response contains hydrated trees | ||
| // dehydrate (normalize) the results and store dehydrated trees | ||
| guard let cacheProvider = cacheProvider else { | ||
| DataConnectLogger | ||
| .debug("Cache provider not initialized yet. Skipping update for \(queryId)") | ||
| return | ||
| } | ||
| do { | ||
| let processor = ResultTreeProcessor() | ||
| let (dehydratedResults, rootObj, impactedRefs) = try processor.dehydrateResults( | ||
| response.jsonResults, | ||
| cacheProvider: cacheProvider, | ||
| requestor: requestor | ||
| ) | ||
|
|
||
| cacheProvider | ||
| .setResultTree( | ||
| queryId: queryId, | ||
| tree: .init( | ||
| data: dehydratedResults, | ||
| cachedAt: Date(), | ||
| lastAccessed: Date(), | ||
| rootObject: rootObj | ||
| ) | ||
| ) | ||
|
|
||
| if let dataConnect { | ||
| for refId in impactedRefs { | ||
| guard let q = dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { | ||
| continue | ||
| } | ||
| Task { | ||
| do { | ||
| try await q.publishCacheResultsToSubscribers(allowStale: true) | ||
| } catch { | ||
| DataConnectLogger | ||
| .warning("Error republishing cached results for impacted queryrefs \(error))") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } catch { | ||
| DataConnectLogger.warning("Error updating cache for \(queryId): \(error)") | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| // Copyright 2025 Google LLC | ||
| // | ||
| // Licensed 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. | ||
|
|
||
| import Foundation | ||
|
|
||
| import FirebaseCore | ||
|
|
||
| // Key to store cache provider in Codables userInfo object. | ||
| let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! | ||
|
|
||
| @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) | ||
| protocol CacheProvider { | ||
| var cacheIdentifier: String { get } | ||
|
|
||
| func resultTree(queryId: String) -> ResultTree? | ||
| func setResultTree(queryId: String, tree: ResultTree) | ||
|
|
||
| func entityData(_ entityGuid: String) -> EntityDataObject | ||
| func updateEntityData(_ object: EntityDataObject) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // Copyright 2025 Google LLC | ||
| // | ||
| // Licensed 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. | ||
|
|
||
| import Foundation | ||
|
|
||
| /// Specifies the cache configuration for a `DataConnect` instance. | ||
| /// | ||
| /// You can configure the cache's storage policy and its maximum size. | ||
| @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) | ||
| public struct CacheSettings: Sendable { | ||
| /// Defines the storage mechanism for the cache. | ||
| public enum Storage: Sendable { | ||
| /// The cache will be written to disk, persisting data across application launches. | ||
| case persistent | ||
| /// The cache will only be stored in memory and will be cleared when the application terminates. | ||
| case memory | ||
| } | ||
|
|
||
| /// The storage mechanism to be used for caching. The default is `.persistent`. | ||
| public let storage: Storage | ||
| /// The maximum size of the cache in bytes. | ||
| /// | ||
| /// This size is not strictly enforced but is used as a guideline by the cache | ||
| /// to trigger cleanup procedures. The default is 100MB (100,000,000 bytes). | ||
| public let maxSizeBytes: UInt64 | ||
|
|
||
| /// Max time interval before a queries cache is considered stale and refreshed from the server | ||
| /// This interval does not imply that cached data is evicted and it can still be accessed using | ||
| /// the `cacheOnly` fetch policy | ||
| public let maxAge: TimeInterval | ||
|
|
||
| /// Creates a new cache settings configuration. | ||
| /// | ||
| /// - Parameters: | ||
| /// - storage: The storage mechanism to use. Defaults to `.persistent`. | ||
| /// - maxSize: The maximum desired size of the cache in bytes. Defaults to 100MB. | ||
| /// - maxAge: The max time interval before a queries cache is considered stale and refreshed | ||
| /// from the server | ||
| public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000, | ||
| maxAge: TimeInterval = 0) { | ||
| self.storage = storage | ||
| maxSizeBytes = maxSize | ||
| self.maxAge = maxAge | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| // Copyright 2025 Google LLC | ||
| // | ||
| // Licensed 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. | ||
|
|
||
| // Used for inline inline hydration of entity values | ||
| @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) | ||
| struct DynamicCodingKey: CodingKey { | ||
| var intValue: Int? | ||
| let stringValue: String | ||
| init?(intValue: Int) { return nil } | ||
| init?(stringValue: String) { self.stringValue = stringValue } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.