Skip to content

Commit

Permalink
Add ability to mark data as stale (HoudiniGraphql#849)
Browse files Browse the repository at this point in the history
* 🚧 UPDATE: stale v2 to v3 ^^

* 🚸 UPDATE: with TODOs

* 👌 FIX: with feedback v0

* ⚡ IMPROVE: stale story

* rename defaultTimeToStale to defaultLifetime

* add garbage collection

* linter

* tests pass

* consolidate cache methods

* fix syntax issue

* unused import

* tests pass

* grammar

* add stale section to cache guide

* link

* fix link

* changeset

* remove unused tests

* clean up links file

---------

Co-authored-by: Alec Aivazis <alec@aivazis.com>
  • Loading branch information
jycouet and AlecAivazis committed Feb 10, 2023
1 parent 760d005 commit 71ad83d
Show file tree
Hide file tree
Showing 32 changed files with 883 additions and 31 deletions.
5 changes: 5 additions & 0 deletions .changeset/big-mice-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini': minor
---

Add support for marking data as stale
4 changes: 2 additions & 2 deletions e2e/sveltekit/src/routes/stores/metadata/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ test.describe('Metadata Page', () => {
await goto_expect_n_gql(page, routes.Stores_Metadata, 1);

expect(displayed).toBe(
'{"fetching":false,"variables":{},"data":{"session":"1234-Houdini-Token-5678"},"errors":null,"partial":false,"source":"network"}'
'{"fetching":false,"variables":{},"data":{"session":"1234-Houdini-Token-5678"},"errors":null,"partial":false,"stale":false,"source":"network"}'
);

//Click the button
// Mutate the data (that will be displayed in the console)
await expect_1_gql(page, 'button[id=mutate]');

expect(displayed).toBe(
'{"fetching":false,"variables":{"id":"5","name":"Hello!"},"data":{"updateUser":{"id":"list-store-user-subunsub:5","name":"Hello!"}},"errors":null,"partial":false,"source":"network"}'
'{"fetching":false,"variables":{"id":"5","name":"Hello!"},"data":{"updateUser":{"id":"list-store-user-subunsub:5","name":"Hello!"}},"errors":null,"partial":false,"stale":false,"source":"network"}'
);
});
});
1 change: 1 addition & 0 deletions e2e/sveltekit/src/routes/stores/mutation/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ test.describe('Mutation Page', () => {
fetching: false,
variables: null,
partial: false,
stale: false,
source: null
};
await expectToBe(page, stry(defaultStoreValues) ?? '', '[id="store-value"]');
Expand Down
2 changes: 1 addition & 1 deletion e2e/sveltekit/src/routes/stores/prefetch-[userId]/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test.describe('prefetch-[userId] Page', () => {
await goto(page, routes.Stores_Prefetch_UserId_2);

const dataDisplayedSSR =
'{"data":{"user":{"id":"store-user-query:2","name":"Samuel Jackson"}},"errors":null,"fetching":false,"partial":false,"source":"network","variables":{"id":"2"}}';
'{"data":{"user":{"id":"store-user-query:2","name":"Samuel Jackson"}},"errors":null,"fetching":false,"partial":false,"source":"network","stale":false,"variables":{"id":"2"}}';

// The page should have the right data directly
await expectToBe(page, dataDisplayedSSR);
Expand Down
90 changes: 82 additions & 8 deletions packages/houdini/src/runtime/cache/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defaultConfigValues, computeID, keyFieldsForType } from '../lib/config'
import { computeKey } from '../lib'
import type { ConfigFile } from '../lib/config'
import { computeID, defaultConfigValues, keyFieldsForType } from '../lib/config'
import { deepEquals } from '../lib/deepEquals'
import { getFieldsForType } from '../lib/selection'
import type {
Expand All @@ -12,10 +13,11 @@ import { GarbageCollector } from './gc'
import type { ListCollection } from './lists'
import { ListManager } from './lists'
import { SchemaManager } from './schema'
import { StaleManager } from './staleManager'
import type { Layer, LayerID } from './storage'
import { InMemoryStorage } from './storage'
import { evaluateKey, flattenList } from './stuff'
import { type FieldSelection, InMemorySubscriptions } from './subscription'
import { InMemorySubscriptions, type FieldSelection } from './subscription'

export class Cache {
// the internal implementation for a lot of the cache's methods are moved into
Expand All @@ -30,6 +32,7 @@ export class Cache {
subscriptions: new InMemorySubscriptions(this),
lists: new ListManager(this, rootID),
lifetimes: new GarbageCollector(this),
staleManager: new StaleManager(this),
schema: new SchemaManager(this),
})

Expand All @@ -53,6 +56,7 @@ export class Cache {
applyUpdates?: string[]
notifySubscribers?: SubscriptionSpec[]
forceNotify?: boolean
forceStale?: boolean
}): SubscriptionSpec[] {
// find the correct layer
const layer = layerID
Expand Down Expand Up @@ -88,15 +92,16 @@ export class Cache {

// reconstruct an object with the fields/relations specified by a selection
read(...args: Parameters<CacheInternal['getSelection']>) {
const { data, partial, hasData } = this._internal_unstable.getSelection(...args)
const { data, partial, stale, hasData } = this._internal_unstable.getSelection(...args)

if (!hasData) {
return { data: null, partial: false }
return { data: null, partial: false, stale: false }
}

return {
data,
partial,
stale,
}
}

Expand Down Expand Up @@ -153,6 +158,33 @@ export class Cache {
setConfig(config: ConfigFile) {
this._internal_unstable.setConfig(config)
}

markTypeStale(type?: string, options: { field?: string; when?: {} } = {}): void {
if (!type) {
this._internal_unstable.staleManager.markAllStale()
} else if (!options.field) {
this._internal_unstable.staleManager.markTypeStale(type)
} else {
this._internal_unstable.staleManager.markTypeFieldStale(
type,
options.field,
options.when
)
}
}

markRecordStale(id: string, options: { field?: string; when?: {} }) {
if (options.field) {
const key = computeKey({ field: options.field, args: options.when ?? {} })
this._internal_unstable.staleManager.markFieldStale(id, key)
} else {
this._internal_unstable.staleManager.markRecordStale(id)
}
}

getFieldTime(id: string, field: string) {
return this._internal_unstable.staleManager.getFieldTime(id, field)
}
}

class CacheInternal {
Expand All @@ -171,6 +203,7 @@ class CacheInternal {
lists: ListManager
cache: Cache
lifetimes: GarbageCollector
staleManager: StaleManager
schema: SchemaManager

constructor({
Expand All @@ -179,20 +212,23 @@ class CacheInternal {
lists,
cache,
lifetimes,
staleManager,
schema,
}: {
storage: InMemoryStorage
subscriptions: InMemorySubscriptions
lists: ListManager
cache: Cache
lifetimes: GarbageCollector
staleManager: StaleManager
schema: SchemaManager
}) {
this.storage = storage
this.subscriptions = subscriptions
this.lists = lists
this.cache = cache
this.lifetimes = lifetimes
this.staleManager = staleManager
this.schema = schema

// the cache should always be disabled on the server, unless we're testing
Expand All @@ -219,6 +255,7 @@ class CacheInternal {
layer,
toNotify = [],
forceNotify,
forceStale,
}: {
data: { [key: string]: GraphQLValue }
selection: SubscriptionSelection
Expand All @@ -229,6 +266,7 @@ class CacheInternal {
toNotify?: FieldSelection[]
applyUpdates?: string[]
forceNotify?: boolean
forceStale?: boolean
}): FieldSelection[] {
// if the cache is disabled, dont do anything
if (this._disabled) {
Expand Down Expand Up @@ -291,6 +329,13 @@ class CacheInternal {
// if we are writing to the display layer we need to refresh the lifetime of the value
if (displayLayer) {
this.lifetimes.resetLifetime(parent, key)

// update the stale status
if (forceStale) {
this.staleManager.markFieldStale(parent, key)
} else {
this.staleManager.setFieldTimeToNow(parent, key)
}
}

// any scalar is defined as a field with no selection
Expand Down Expand Up @@ -726,10 +771,10 @@ class CacheInternal {
parent?: string
variables?: {}
stepsFromConnection?: number | null
}): { data: GraphQLObject | null; partial: boolean; hasData: boolean } {
}): { data: GraphQLObject | null; partial: boolean; stale: boolean; hasData: boolean } {
// we could be asking for values of null
if (parent === null) {
return { data: null, partial: false, hasData: true }
return { data: null, partial: false, stale: false, hasData: true }
}

const target = {} as GraphQLObject
Expand All @@ -743,6 +788,9 @@ class CacheInternal {
// that happens after we process every field to determine if its a partial null
let cascadeNull = false

// Check if we have at least one stale data
let stale = false

// if we have abstract fields, grab the __typename and include them in the list
const typename = this.storage.get(parent, '__typename').value as string
// collect all of the fields that we need to write
Expand All @@ -758,6 +806,12 @@ class CacheInternal {
// look up the value in our store
const { value } = this.storage.get(parent, key)

// If we have an explicite null, that mean that it's stale and the we should do a network call
const dt_field = this.staleManager.getFieldTime(parent, key)
if (dt_field === null) {
stale = true
}

// in order to avoid falsey identifying the `cursor` field of a connection edge
// as missing non-nullable data (and therefor cascading null to the response) we need to
// count the number of steps since we saw a connection field and if we are at the
Expand Down Expand Up @@ -834,6 +888,10 @@ class CacheInternal {
partial = true
}

if (listValue.stale) {
stale = true
}

if (listValue.hasData || value.length === 0) {
hasData = true
}
Expand All @@ -857,6 +915,10 @@ class CacheInternal {
partial = true
}

if (objectFields.stale) {
stale = true
}

if (objectFields.hasData) {
hasData = true
}
Expand All @@ -874,6 +936,7 @@ class CacheInternal {
// our value is considered true if there is some data but not everything
// has a full value
partial: hasData && partial,
stale: hasData && stale,
hasData,
}
}
Expand Down Expand Up @@ -916,12 +979,13 @@ class CacheInternal {
variables?: {}
linkedList: LinkedList
stepsFromConnection: number | null
}): { data: LinkedList<GraphQLValue>; partial: boolean; hasData: boolean } {
}): { data: LinkedList<GraphQLValue>; partial: boolean; stale: boolean; hasData: boolean } {
// the linked list could be a deeply nested thing, we need to call getData for each record
// we can't mutate the lists because that would change the id references in the listLinks map
// to the corresponding record. can't have that now, can we?
const result: LinkedList<GraphQLValue> = []
let partialData = false
let stale = false
let hasValues = false

for (const entry of linkedList) {
Expand All @@ -947,7 +1011,12 @@ class CacheInternal {
}

// look up the data for the record
const { data, partial, hasData } = this.getSelection({
const {
data,
partial,
stale: local_stale,
hasData,
} = this.getSelection({
parent: entry,
selection: fields,
variables,
Expand All @@ -960,6 +1029,10 @@ class CacheInternal {
partialData = true
}

if (local_stale) {
stale = true
}

if (hasData) {
hasValues = true
}
Expand All @@ -968,6 +1041,7 @@ class CacheInternal {
return {
data: result,
partial: partialData,
stale,
hasData: hasValues,
}
}
Expand Down
24 changes: 24 additions & 0 deletions packages/houdini/src/runtime/cache/gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export class GarbageCollector {
}

tick() {
// get the current time of the tick
const dt_tick = Date.now().valueOf()
const config_max_time = this.cache._internal_unstable.config.defaultLifetime

// look at every field of every record we know about
for (const [id, fieldMap] of this.lifetimes.entries()) {
for (const [field, lifetime] of fieldMap.entries()) {
Expand All @@ -33,6 +37,9 @@ export class GarbageCollector {
continue
}

// --- ----------------- ---
// --- Part 1 : lifetime ---
// --- ----------------- ---
// there are no active subscriptions for this field, increment the lifetime count
fieldMap.set(field, lifetime + 1)

Expand All @@ -49,6 +56,23 @@ export class GarbageCollector {
if ([...fieldMap.keys()].length === 0) {
this.lifetimes.delete(id)
}

// remove the field from the stale manager
this.cache._internal_unstable.staleManager.delete(id, field)
}

// --- ------------------- ---
// --- Part 2 : fieldTimes ---
// --- ------------------- ---
if (config_max_time && config_max_time > 0) {
// if the field is older than x... mark it as stale
const dt_valueOf = this.cache.getFieldTime(id, field)

// if we have no dt_valueOf, it's already stale
// check if more than the max time has passed since it was marked stale
if (dt_valueOf && dt_tick - dt_valueOf > config_max_time) {
this.cache._internal_unstable.staleManager.markFieldStale(id, field)
}
}
}
}
Expand Down
Loading

0 comments on commit 71ad83d

Please sign in to comment.