From 431523c19bd7879dde9bf1a20643dba0a0e9a12d Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Mon, 25 Nov 2024 23:44:37 -0800 Subject: [PATCH 1/2] rename InstanceCache to ResourceManager and move file location: --- packages/core-types/src/-private.ts | 2 +- .../legacy-compat/src/builders/save-record.ts | 4 +-- .../legacy-network-handler/fetch-manager.ts | 6 ++-- packages/store/src/-private.ts | 8 ++--- .../resource-manager.ts} | 29 +++++++++++-------- .../record-arrays/identifier-array.ts | 2 +- packages/store/src/-private/store-service.ts | 8 ++--- 7 files changed, 32 insertions(+), 27 deletions(-) rename packages/store/src/-private/{caches/instance-cache.ts => managers/resource-manager.ts} (94%) diff --git a/packages/core-types/src/-private.ts b/packages/core-types/src/-private.ts index a08ec018525..c2b39b2b7c7 100644 --- a/packages/core-types/src/-private.ts +++ b/packages/core-types/src/-private.ts @@ -71,7 +71,7 @@ type GlobalKey = | 'DEBUG_MAP' | 'IDENTIFIERS' | 'DOCUMENTS' - // @ember-data/store InstanceCache + // @ember-data/store ResourceManager | 'CacheForIdentifierCache' | 'RecordCache' | 'StoreMap' diff --git a/packages/legacy-compat/src/builders/save-record.ts b/packages/legacy-compat/src/builders/save-record.ts index 3aaae565901..54e83d7f34e 100644 --- a/packages/legacy-compat/src/builders/save-record.ts +++ b/packages/legacy-compat/src/builders/save-record.ts @@ -2,7 +2,7 @@ * @module @ember-data/legacy-compat/builders */ import { recordIdentifierFor, storeFor, type StoreRequestInput } from '@ember-data/store'; -import type { InstanceCache } from '@ember-data/store/-private'; +import type { ResourceManager } from '@ember-data/store/-private'; import { assert } from '@warp-drive/build-config/macros'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; import type { Cache } from '@warp-drive/core-types/cache'; @@ -26,7 +26,7 @@ function _resourceIsFullDeleted(identifier: StableRecordIdentifier, cache: Cache return cache.isDeletionCommitted(identifier) || (cache.isNew(identifier) && cache.isDeleted(identifier)); } -function resourceIsFullyDeleted(instanceCache: InstanceCache, identifier: StableRecordIdentifier): boolean { +function resourceIsFullyDeleted(instanceCache: ResourceManager, identifier: StableRecordIdentifier): boolean { const cache = instanceCache.cache; return !cache || _resourceIsFullDeleted(identifier, cache); } diff --git a/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts b/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts index 1e30751209e..0a3c321b185 100644 --- a/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts +++ b/packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts @@ -6,9 +6,9 @@ import { createDeferred } from '@ember-data/request'; import type Store from '@ember-data/store'; import type { FindRecordQuery, - InstanceCache, Request, RequestStateService, + ResourceManager, SaveRecordMutation, } from '@ember-data/store/-private'; import { coerceId } from '@ember-data/store/-private'; @@ -285,7 +285,7 @@ export class FetchManager { } } -function _isEmpty(instanceCache: InstanceCache, identifier: StableRecordIdentifier): boolean { +function _isEmpty(instanceCache: ResourceManager, identifier: StableRecordIdentifier): boolean { const cache = instanceCache.cache; if (!cache) { return true; @@ -297,7 +297,7 @@ function _isEmpty(instanceCache: InstanceCache, identifier: StableRecordIdentifi return (!isNew || isDeleted) && isEmpty; } -function _isLoading(cache: InstanceCache, identifier: StableRecordIdentifier): boolean { +function _isLoading(cache: ResourceManager, identifier: StableRecordIdentifier): boolean { const req = cache.store.getRequestStateService(); // const fulfilled = req.getLastRequestForRecord(identifier); const isLoaded = cache.recordIsLoaded(identifier); diff --git a/packages/store/src/-private.ts b/packages/store/src/-private.ts index 532b54b8e04..3401295406a 100644 --- a/packages/store/src/-private.ts +++ b/packages/store/src/-private.ts @@ -4,7 +4,7 @@ export { Store, storeFor } from './-private/store-service'; -export { recordIdentifierFor } from './-private/caches/instance-cache'; +export { recordIdentifierFor } from './-private/managers/resource-manager'; export { CacheHandler, type StoreRequestContext } from './-private/cache-handler/handler'; export { type CachePolicy } from './-private/cache-handler/types'; @@ -14,7 +14,7 @@ export { isStableIdentifier } from './-private/caches/identifier-cache'; export { constructResource } from './-private/utils/construct-resource'; export type { Document } from './-private/document'; -export type { InstanceCache } from './-private/caches/instance-cache'; +export type { ResourceManager } from './-private/managers/resource-manager'; export type { FindRecordQuery, @@ -41,11 +41,11 @@ export { export { RecordArrayManager, fastPush } from './-private/managers/record-array-manager'; // leaked for private use / test use, should investigate removing -export { _clearCaches } from './-private/caches/instance-cache'; +export { _clearCaches } from './-private/managers/resource-manager'; export { peekCache, removeRecordDataFor } from './-private/caches/cache-utils'; // @ember-data/model needs these temporarily -export { setRecordIdentifier, StoreMap } from './-private/caches/instance-cache'; +export { setRecordIdentifier, StoreMap } from './-private/managers/resource-manager'; export { setCacheFor } from './-private/caches/cache-utils'; export { normalizeModelName as _deprecatingNormalize } from './-private/utils/normalize-model-name'; export type { StoreRequestInput } from './-private/cache-handler/handler'; diff --git a/packages/store/src/-private/caches/instance-cache.ts b/packages/store/src/-private/managers/resource-manager.ts similarity index 94% rename from packages/store/src/-private/caches/instance-cache.ts rename to packages/store/src/-private/managers/resource-manager.ts index 1fa2a0bd4f6..fbcb631c3f3 100644 --- a/packages/store/src/-private/caches/instance-cache.ts +++ b/packages/store/src/-private/managers/resource-manager.ts @@ -16,12 +16,12 @@ import type { } from '@warp-drive/core-types/spec/json-api-raw'; import type { OpaqueRecordInstance } from '../../-types/q/record-instance'; +import { CacheForIdentifierCache, removeRecordDataFor, setCacheFor } from '../caches/cache-utils'; import RecordReference from '../legacy-model-support/record-reference'; -import { CacheCapabilitiesManager } from '../managers/cache-capabilities-manager'; -import type { CacheManager } from '../managers/cache-manager'; import type { CreateRecordProperties, Store } from '../store-service'; import { ensureStringId } from '../utils/coerce-id'; -import { CacheForIdentifierCache, removeRecordDataFor, setCacheFor } from './cache-utils'; +import { CacheCapabilitiesManager } from './cache-capabilities-manager'; +import type { CacheManager } from './cache-manager'; type Destroyable = { isDestroyed: boolean; @@ -106,7 +106,12 @@ type Caches = { reference: WeakMap; }; -export class InstanceCache { +/** + * The ResourceManager + * + * @internal + */ +export class ResourceManager { declare store: Store; declare cache: Cache; declare _storeWrapper: CacheCapabilitiesManager; @@ -207,7 +212,7 @@ export class InstanceCache { if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.log(`InstanceCache: created Record for ${String(identifier)}`, properties); + console.log(`ResourceManager: created Record for ${String(identifier)}`, properties); } } @@ -261,7 +266,7 @@ export class InstanceCache { this.store._requestCache._clearEntries(identifier); if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.log(`InstanceCache: disconnected ${String(identifier)}`); + console.log(`ResourceManager: disconnected ${String(identifier)}`); } } @@ -278,7 +283,7 @@ export class InstanceCache { } if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.groupCollapsed(`InstanceCache: unloading record for ${String(identifier)}`); + console.groupCollapsed(`ResourceManager: unloading record for ${String(identifier)}`); } // TODO is this join still necessary? @@ -295,7 +300,7 @@ export class InstanceCache { if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.log(`InstanceCache: destroyed record for ${String(identifier)}`); + console.log(`ResourceManager: destroyed record for ${String(identifier)}`); } } @@ -304,7 +309,7 @@ export class InstanceCache { removeRecordDataFor(identifier); if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.log(`InstanceCache: destroyed cache for ${String(identifier)}`); + console.log(`ResourceManager: destroyed cache for ${String(identifier)}`); } } else { this.disconnect(identifier); @@ -313,7 +318,7 @@ export class InstanceCache { this.store._requestCache._clearEntries(identifier); if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.log(`InstanceCache: unloaded RecordData for ${String(identifier)}`); + console.log(`ResourceManager: unloaded RecordData for ${String(identifier)}`); // eslint-disable-next-line no-console console.groupEnd(); } @@ -372,7 +377,7 @@ export class InstanceCache { if (LOG_INSTANCE_CACHE) { // eslint-disable-next-line no-console - console.log(`InstanceCache: updating id to '${id}' for record ${String(identifier)}`); + console.log(`ResourceManager: updating id to '${id}' for record ${String(identifier)}`); } const existingIdentifier = this.store.identifierCache.peekRecordIdentifier({ type, id }); @@ -396,7 +401,7 @@ function _resourceIsFullDeleted(identifier: StableRecordIdentifier, cache: Cache return cache.isDeletionCommitted(identifier) || (cache.isNew(identifier) && cache.isDeleted(identifier)); } -export function resourceIsFullyDeleted(instanceCache: InstanceCache, identifier: StableRecordIdentifier): boolean { +export function resourceIsFullyDeleted(instanceCache: ResourceManager, identifier: StableRecordIdentifier): boolean { const cache = instanceCache.cache; return !cache || _resourceIsFullDeleted(identifier, cache); } diff --git a/packages/store/src/-private/record-arrays/identifier-array.ts b/packages/store/src/-private/record-arrays/identifier-array.ts index d884f92f0ec..5084db5c59e 100644 --- a/packages/store/src/-private/record-arrays/identifier-array.ts +++ b/packages/store/src/-private/record-arrays/identifier-array.ts @@ -19,7 +19,7 @@ import type { Links, PaginationLinks } from '@warp-drive/core-types/spec/json-ap import type { OpaqueRecordInstance } from '../../-types/q/record-instance'; import { isStableIdentifier } from '../caches/identifier-cache'; -import { recordIdentifierFor } from '../caches/instance-cache'; +import { recordIdentifierFor } from '../managers/resource-manager'; import type { RecordArrayManager } from '../managers/record-array-manager'; import type { Store } from '../store-service'; import { NativeProxy } from './native-proxy-type-fix'; diff --git a/packages/store/src/-private/store-service.ts b/packages/store/src/-private/store-service.ts index b600bcc4925..ad7ade5de9d 100644 --- a/packages/store/src/-private/store-service.ts +++ b/packages/store/src/-private/store-service.ts @@ -43,13 +43,13 @@ import type { StoreRequestInput } from './cache-handler/handler'; import type { CachePolicy } from './cache-handler/types'; import { IdentifierCache } from './caches/identifier-cache'; import { - InstanceCache, + ResourceManager, peekRecordIdentifier, preloadData, recordIdentifierFor, resourceIsFullyDeleted, storeFor, -} from './caches/instance-cache'; +} from './managers/resource-manager'; import type { Document } from './document'; import type RecordReference from './legacy-model-support/record-reference'; import { getShimClass } from './legacy-model-support/shim-model-class'; @@ -534,7 +534,7 @@ export class Store extends BaseClass { // Private declare _graph?: Graph; declare _requestCache: RequestStateService; - declare _instanceCache: InstanceCache; + declare _instanceCache: ResourceManager; declare _documentCache: Map< StableDocumentIdentifier, Document @@ -590,7 +590,7 @@ export class Store extends BaseClass { // private this._requestCache = new RequestStateService(this); - this._instanceCache = new InstanceCache(this); + this._instanceCache = new ResourceManager(this); this._documentCache = new Map(); this.isDestroying = false; From 3352349217368bdc9635175586bc06923273f7f1 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Tue, 26 Nov 2024 02:23:14 -0800 Subject: [PATCH 2/2] doc: describe the GC we are about to implement --- .../src/-private/managers/resource-manager.ts | 170 +++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/packages/store/src/-private/managers/resource-manager.ts b/packages/store/src/-private/managers/resource-manager.ts index fbcb631c3f3..fd4641b36ee 100644 --- a/packages/store/src/-private/managers/resource-manager.ts +++ b/packages/store/src/-private/managers/resource-manager.ts @@ -107,7 +107,175 @@ type Caches = { }; /** - * The ResourceManager + * ## ResourceManager + * + * The ResourceManager is responsible for managing instance + * creation and retention for managed ui Objects. + * + * Managed UI Objects include: + * - (Reactive) UIDocuments + * - (Reactive) UIArrays + * - (Reactive) UIRecords + * + * Every Managed UI Object has a well-known identity token: + * - UIDocuments => StableDocumentIdentifier + * - UIArrays => StableDocumentIdentifier + * - UIRecords => StableRecordIdentifier + * + * This identity token is a CacheKey that can be safely used + * to reference the UI Object and retrieve its associated data + * from the Cache without needing to retain the UI Object itself. + * + * Data in the cache keyed to these CacheKeys includes: + * - StableDocumentIdentifier => StructuredDocument (the request response) + * - StableDocumentIdentifier => ResourceDocument (the parsed and processed content of the request) + * - StableRecordIdentifier => Resource (the data for individual records) + * - StableRecordIdentifier => GraphEdge (data describing the relationship between one resource and another) + * + * The ResourceManager has three modes: + * - Strong (default - until v6) + * - Weak + auto GC + * - Weak + manual GC (defaut after v6) + * + * + * ---------------------------------------------------------------- + * + * ### Strong Mode + * + * In strong mode, Managed UI Objects are retained forever unless + * explicitly destroyed by the application. Associated data in the + * cache is similarly retained forever unless explicitly removed. + * + * Strong mode comes with inherent risks: + * - memory usage may grow to a problematic size + * - manually managing release can result in unsafe teardown occurring + * - access to legacy APIs (only allowed in strong mode) like + * unloadRecord and unloadAll can result in application bugs, + * unsafe teardown, and broken relationships. + * + * While there are risks, strong mode is a great choice for applications + * that understand these risks and are able to manage them effectively. + * + * Applications that may wish to use strong mode will typically be + * those that utilize small quantities of data that changes infrequently. + * + * The primary benefit of strong mode is that it incurs less overhead + * on accessing data because UI Objects do not need to ever re-instantiate, + * and their instance is quicker to retrieve due to not requiring a + * `.deref()` resolution. + * + * + * ---------------------------------------------------------------- + * + * ### Weak Mode + * + * This is managed via WeakRef. In a WeakRef, the *value* is weakly + * retained while the *key* is strongly retained. This means that + * our CacheKey is strongly retained, but the UI Object is free to + * be collected if the application no longer has a reference to it. + * + * On it's own, this already provides a significant reduction in + * longterm memory usage for applications that have a lot of UI Objects: + * but we can do more! + * + * In addition to utlizing WeakRefs, the ResourceManager manages a + * a FinalizationRegistry and registers the Managed UI Object with it. + * + * This allows us to be notified when a UI Object is GC'd and either + * update bookkeeping or perform a more advanced GC operation of our + * own. Whenever a UI Object is GC'd, we mark it's CacheKey for potential + * cleanup. + * + * This is where the Auto vs Manual GC comes into the picture. + * + * - Auto GC: The ResourceManager performs a GC operation using the + * FinalizationRegistry callback as a trigger to schedule the GC. + * - Manual GC: The ResourceManager only used the FinalizationRegistry + * callback to update bookkeeping and relies on the application to + * trigger the GC operation. + * + * Which is best primarily depends on what framework you are using, + * how well you understand your application's scheduling needs and workload, + * and how much control you want to have over the GC process. Currently, + * we think that leaving this decision to the application is the best choice, + * at least until we've had more time to observe how and when applications + * are using the GC in the wild and gathered feedback. + * + * > [!TIP] + * > A manual GC operation is always possible when using auto GC, but it + * > is almost non-sensical to call the GC manually as there would likely be no + * > work to do. + * + * + * ---------------------------------------------------------------- + * + * ### Understanding the GC Process + * + * The GC Presumes that the application has fully migrated to using Request based + * patterns and eliminated the use of all legacy resource-centric patterns. + * + * This presumption is necessary because the legacy resource-centric patterns are + * not compatible with the concept of GC, because it is not possible to determine + * when a resource in a relationship is no longer needed by the application except + * for the few cases where it is part of a group of resources that are collectively + * no longer accessible at all. + * + * We use a [Tracing GC approach](https://en.wikipedia.org/wiki/Tracing_garbage_collection) + * but with a twist: reachability refers to the request graph, not the relationship graph. + * + * There are effectively two separate graph traversals possible of data in the WarpDrive Cache: + * - the graph of which requests include which resources + * - the graph of relationships between resources + * + * In our GC, we ignore the relationship graph and focus on the request graph, treating the + * ResourceDocuments as the roots. If the CacheKey for a ResourceDocument has been marked, + * and no other ResourceDocument can reach that document via a relationship of a resource it + * contains directly, then it can be GC'd. + * + * We ignore the relationship graph specifically because every cache insertion is an upsert + * operation on resources. Over time lots of resources become reachable from each other in + * the cache that were not originally reachable from each other in the request graph. While + * this is useful efficient storage and retrieval and mutation management, it makes it mostly + * useless for GC purposes. We care not about what is "physically" reachable from a resource + * but what is "conceptually" reachable from the application's perspective, trusting what the + * application has previously told us via the request graph. + * + * Resources are a bit trickier because there's a few edge cases we have to keep in mind: + * - A resource may have been created on the client and thus not yet be part of any document + * except within the mutated state of a relationship. + * - A resource may have been added to the state of a relationship on a record within a document + * it was not originally part of. + * - A resource may have been added to the cache but never materialized into a UIRecord. + * + * This third nuance is the most interesting because it means that quite easily we could end up + * with orphaned state in the cache if all we rely on is the mark from the FinalizationRegistry + * to generate the list of candidates for GC. For this reason, we always consider any resource + * that was never materialized into a UIRecord as a candidate for GC and initialize its state as + * marked. + * + * With the above in mind, we iterate the list of marked CacheKeys for resources: if the resource + * no longer belongs to any known request, we consider it a candidate. + * + * - if the candidate is not in any relationships, we remove it from the cache + * - if the candidate is only in implicit relationships, we remove it from the cache + * - if the candidate is in a relationship with a non-candidate due to a mutation, we keep it and + * ensure it is added to that document's list of resources. We also add it to a temporary "kept" list. + * - if the candidate is only in relationships with other candidates, it continues be a candidate. + * + * Once we have iterated all candidates, if no records were kept, then we can remove all remaining + * candidates. If records were kept, we do another pass. Kept records become "non-candidates". + * + * - if the candidate is in a relationship with a kept record, and the document the kept record + * was added to has other resources of the same type, we keep the candidate and ensure it is added + * to that document's list of resources. We also add it to a new "kept" list. + * - if the candidate is only in relationships with other candidates, it continues to be a candidate. + * + * We repeat the above process until no new records are kept in a pass, at which point we can remove + * all remaining candidates. + * + * This process may occassionally result in keeping more records than the application actually needed + * us to keep; however, it also ensures that we do not remove records that the application still needs + * and provides a way to ensure that those records are still capable of being GC'd in the future. * * @internal */