Skip to content
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

feat: ResourceManager (tracing GC approach) #9612

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core-types/src/-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ type GlobalKey =
| 'DEBUG_MAP'
| 'IDENTIFIERS'
| 'DOCUMENTS'
// @ember-data/store InstanceCache
// @ember-data/store ResourceManager
| 'CacheForIdentifierCache'
| 'RecordCache'
| 'StoreMap'
Expand Down
4 changes: 2 additions & 2 deletions packages/legacy-compat/src/builders/save-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions packages/store/src/-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,7 +106,180 @@ type Caches = {
reference: WeakMap<StableRecordIdentifier, RecordReference>;
};

export class InstanceCache {
/**
* ## 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
* `<WeakRef>.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
*/
export class ResourceManager {
declare store: Store;
declare cache: Cache;
declare _storeWrapper: CacheCapabilitiesManager;
Expand Down Expand Up @@ -207,7 +380,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);
}
}

Expand Down Expand Up @@ -261,7 +434,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)}`);
}
}

Expand All @@ -278,7 +451,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?
Expand All @@ -295,7 +468,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)}`);
}
}

Expand All @@ -304,7 +477,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);
Expand All @@ -313,7 +486,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();
}
Expand Down Expand Up @@ -372,7 +545,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 });
Expand All @@ -396,7 +569,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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading