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

[RFC] persistable state service #67931

Closed
wants to merge 12 commits into from
Closed
347 changes: 347 additions & 0 deletions rfcs/text/0011_persistable_state_service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
- Start Date: 2020-06-02
- RFC PR: (leave this empty)
- Kibana Issue: (leave this empty)

# Summary

Pluggable state from registry items can often end up stored inside saved objects, for instance:
Embeddables, Expression strings, Url generators, ...

When plugin A (persister) stores some state that belongs to another plugin B (registrator) a few issues arise:
- How does persister know if plugin B state contains references to saved objects
- How does persister migrate the saved object when it contains state that belongs to plugin B
- How does persister know if state that belongs to plugin B contains state that belongs to plugin C

In order to solve the problem we are proposing implementing a `PersistableStateRegistry` to which registrators can register `PersistableStateDefinition` which persisters can then use to do the above tasks.

# Basic example
ppisljar marked this conversation as resolved.
Show resolved Hide resolved

registrator plugin registers its `PersistableStateDefinition`

```ts

export const MY_STATE_ID = 'MyState';

interface MyStateV76 extends PersistableState {
object: string,
val: number,
}

interface MyStateV77 extends PersistableState {
objectId: string,
val: number,
}

export interface MyState extends PersistableState {
objectId: string,
value: number,
}

type MyStateOld = MyStateV76 | MyStateV77 | MyState;

const migrate = (state: MyStateOld, version: string) => {
let v76 = version === '7.6' ? state as MyStateV76 : undefined;
let v77 = version === '7.7' ? state as MyStateV77 : undefined;
let v78 = version === '7.8' ? state as MyState : undefined;
if (v76) {
v77 = { objectId: v76.object, val: v76.val };
}
if (v77) {
v78 = { objectId: v77.objectId, value: v77.val };
}

return v78;
}

const inject = (state: MyState, savedObjectReferences: SavedObjectReference[]) => {
return {
...state,
objectId: savedObjectReferences.find(ref => ref.name = 'mystate.objectId')?.id;
}
}

const extract = (state: MyState) => {
const references = [{ name: 'objectId', id: state.objectId, type: 'savedObject' }];
return [{ ...state, objectId: 'mystate.objectId' }, references];
}

export type MyStatePersistableStateDefinition = PersistableStateDefinition<MyOldState, MyState>;

export const persistableStateDefinition: MyStatePersistableStateDefinition = { migrate, inject, extract };

persistableStateService.register(MY_STATE_ID, persistableStateDefinition);
```

Persister plugin can then use the service to prepare state for saving or get it ready after loading.

```ts
const stateReadyForPersisting = persistableStateService.get(id),beforeSave(myState);
const stateReadyForConsuming = persistableStateService.get(id).afterLoad(await savedObjectsClient.get(...));
```

# Motivation

We need a formal way for authors of registry implementations to add migrations for this data. Since they may not own the saved object that their implementation ends up in, we can't rely on saved object migrations as it exists today (not to mention data that ends up in URLs that may need to be migrated).

We also need to make sure that all the persited state containing references to saved objects extracts those before being saved and injects them later.
ppisljar marked this conversation as resolved.
Show resolved Hide resolved

# Detailed design

We plan to implement `PersistableStateRegistry ` which will be exposed under `PersitableState` plugin

```ts
export interface PersistableState extends Serializable {}

export interface PersistableStateDefinition<
Old extends PersistableState = PersistableState,
P extends PersistableState = PersistableState
> {
id: string,
// migrate function receives state and version string and should return latest state version
// default is identity function
migrate: (state: Old, version: string) => P,
// inject function receives state and a list of references and should return state with references injected
// default is identity function
inject: (state: P, references: SavedObjectReference[]) => P,
// extract function receives state and should return state with references extracted and array of references
// default returns same state with empty reference array
extract: (state: P) => { state: P, references: SavedObjectReference[] }
}

class PersistableStateRegistry {
setup(core, plugins) {
return {
// if one of the functions is not provided default is used
// so we can always assume that all of the functions are available for every persistable state
register: (id: string, definition: Partial<PersistableStateDefinition>) => void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two questions here about Partial<PersistableStateDefinition>:

  1. This will allow register('myState', { id: 'whatever' }); which I assume is not desired.
  2. Partial will also allow the definition to be an empty object, which is pointless (though perhaps harmless?)

Maybe better to do Migrate | Inject | Extract so that you are forced to provide at least one of those, and id is not allowed?

}
}
start(core, plugins) {
return {
// get will always return. If requested persistable state is not defined it will return default set of functions.
get: (id: string) => PersistableStateDefinition,
ppisljar marked this conversation as resolved.
Show resolved Hide resolved
// takes the state, extracts the references and returns them + version string
beforeSave: (id: string, state: PersistableState) => [PersistableState, SavedObjectReference[], string],
ppisljar marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will the service be able to extract and return the version string from an arbitrary state? I don't see any delegate method returning this information on PersistableStateDefinition.

Also minor, but return type inconsistency

// obj
extract: (state: P) => { state: P, references: SavedObjectReference[] }
// array
beforeSave: (id: string, state: PersistableState) => [PersistableState, SavedObjectReference[], string],

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract is taking in latest state and should extract references from it (no version involved)

// takes the state, references and version and returns latest (migrated) state with references injected
afterLoad: (id: string, state: PersistableState, references: SavedObjectReference[], version: string) => PersistableState,
Comment on lines +125 to +126
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the consumer is in charge of passing down the associated SavedObjectReferences? If I see how this would work with states contained inside a saved objects, It's harder for things that will not be directly coming from SO, such as localStorage?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract and inject (and thus afterLoad and beforeSave) are actually only relevant when using with saved objects. when storing to url or local storage there is no need to extract the references

}
}
}
```

Every plugin that exposes its state should register its `PersistableStateDefinition`. In this definition he needs to expose `migrate` function which can take in any state version and migrate it to the latest state as well as `inject` and `extract` functions, which should be able to inject and extract saved object references from the latest state version.

This same persitableStateDefinition should also be exposed on the plugin contract. On top of that plugin also needs to export type definition for the latest state version. Name of type definition should not include a version number but always be the same. (see the basic example)

Plugins consuming state not owned by them should always use persistableStateDefinion of the registrator plugin. When loading state they need to:

`const state = persistabelStateDefinition.inject(persistableStateDefinition.migrate(loadedState, version), references)`

and when saving state they always need to:

`const { state, references } = persistableStateDefinition.extract(runtimeState)` and store references separatly.

This way consuming plugin can always be sure that its working with the latest state version.

# Server or Client ?

Persistable state service will be available on both, server and client, under same interface. We suggest to register your persistable state definition on both client and server even if you are using just one of those as you cannot know where the consuming side is running.


# Consuming and edge cases
ppisljar marked this conversation as resolved.
Show resolved Hide resolved

When plugin A wants to store some state that it does not own it should check with persistableStateRegistry for a registered persistable state definition and execute `beforeSave` function.

```ts
const stateForSaving = persistableStateRegistry.beforeSave(id, myState);

```

WARNING: If state id is known at compile time we should rather import the correct utilities directly from the registrator plugin. As using the registry can hide dependencies between plugins when possible plugins should directly depend on plugins providing the state they want to store.

```ts
import { extract } from 'src/plugins/pluginOwningState';
const stateForSaving = extract(myState);
```
Comment on lines +160 to +165
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the static import here, can't this be achieved using setup contract instead?

Also:

If state id is known at compile time we should rather import the correct utilities directly from the registrator plugin

As it's supposed to be working using the global registry anyway, what about 'prefer one way to do things' ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using global registry when you know the id of the state you are working with at compile time is not desired as introduces indirect dependencies (you define dependency of persistable state service, but in reality you depend on pluginOwningState, so you should rather work with plugin owning state directly.

wheter the methods are exposed on the contract or staticly is imo implementation detail of plugin exposing them, i think static exports are prefered when possible but i don't think that should be defined here.


## Handling corrupt state

In current proposal handling of corrupt state is up to the implementator of PersistableStateDefinition. State incoming to migrate function could not match with version string provided so author should validate the incoming state first.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same note on corrupt state also applies to inject & extract though too, right? For cases where you don't know the ID at compile time and are therefore using the registry, you won't get type safety from TS anyway since all we know is that the state must be serializable.

So state should not be trusted & should always be validated by the registrant of inject/extract/migrate. Perhaps a more explicit way to word this is "Migrate, extract, and inject functions must never return invalid or malformed state, and are therefore responsible for checking that the provided values are in the expected shape."

FWIW I don't think it's worth yet going down the road of a formalized way of doing validation, config-schema or otherwise... for now it feels sufficient to simply make sure folks registering these definitions know it's up to them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, yeah i guess theoretically that's right. however when loading you should always do:
inject(migrate(state, version), references) ... so inject function doesn't need to do any checking
when saving you always do extract(state) where your state should already be typechecked at this point, so it should be safe as well.


## Handling enhancements

Related github issue: https://github.com/elastic/kibana/issues/68880

The registrator needs to explicitly support enhancements. Its state should contain an `enhancement` property which is an object where every key represents a state belonging to enhancer.

Drilldown plugin (enhancer) adds to the state of embeddable (registrator):

```ts
interface EnhancedDrilldownEmbeddableInput extends EmbeddableInput {
enhancements: {
drillDowns: { events?: Events[]; }
}
}
Comment on lines +175 to +184
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is under the registrator's responsibility to handle these 'enhancements' anyway (and not the service/registry), Isn't that a specific implementation from an existing need/structure from our code more than a generic solution that have to be applied everywhere?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree, this is not something that should be applied everywhere, but its listed here under the examples as Stacey had questions about how that would work, so i just wanted to demonstrate that this is not an issue for persistable state registry

```
Embeddable (registrator) is aware that it has an `enhancements` property on its state, where every key represents state of another plugin.

```ts
const inject = (state, references) => {
Object.getKeys(state.enhancements).forEach(key => {
State.enhancements[key] = persistableStateRegistry.get(key).inject(state.enhancements[key], references);
}
}
```

And drilldown plugin registers its state with persistableStateService.

## what about state which extends some base state ?

With embeddables we currently have `SpecializedState extends BaseState` scenario. If possible don't do this but rather use the enhancements pattern described above.

Migrations can still work, but there are edgecases they can't handle. For example `embeddable-baseInput` migrator would do something like:

```ts
const migrate = (state, version) => {
// migrate the base input properties

// run the extended type migration which will migrate the rest of the properties
return persistableStateRegistry.get(state.type).migrate(state, version);
}

```

or do it the other way around, first running the extending class migration and then the base one.

However there could be clashes using this approach due to conflicts with property names, so special care needs to be taken to not run into them. For this reason we suggest migrating towards enhancements pattern.

For example if base class has a property named `x` which it wants to migrate to `y` and extended class has a property named `y` which it wants to migrate to `z` this will only work if we do extending class migration first, the other way around we will lose information during migration.


## Use a `{pluginId}+{stateType}` pattern for your ID

To avoid clashes with other unknown plugins.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put some examples to show what you mean by stateType?

Not just to avoid clashes with unknown plugins, but so the registrator and the enhancer both know exactly what id to use.

Enhancer knows to register their enhancements with drilldowns-{EMBEDDABLE_MIGRATOR} and embeddable knows to look up state.enhancements[key]-{EMBEDDABLE_MIGRATOR}.

Above enhancements example should be updated to use the key.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put some examples to show what you mean by stateType?

++

Enhancer knows to register their enhancements with drilldowns-{EMBEDDABLE_MIGRATOR} and embeddable knows to look up state.enhancements[key]-{EMBEDDABLE_MIGRATOR}.

Do we want to recommend that registrants export these as consts or in an enum or something? This is implied in the examples but not explicitly stated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am not sure if the registrator and enhancer need to know about what id to use ?

for example lets take a look into drilldowns:

  • drilldowns plugin registers a drilldowns-drilldownState persistable state definition
  • 3rdparty plugin registers a 3rdparty-drilldownState persistable state definition

they both add to dashboardState.enhancements[key] ... when dashboard is doing migration it simply checks persistable state service for the key which for our drilldowns will be drilldowns-drildownState and for 3rd party drilldown it will be 3rdparty-drilldownState .


A plugin could have a single `stateType`, for example `visualizations` plugin has a `savedVis` state, so it use `visualizations-savedVis` as id. Other plugins might have multiple state types they want to register. Lets say that we introduce a new state type to `visualizations` plugin called `visState` we would use `visualizations-visState` as id.

A good example of plugin exposing multiple state types is expressions plugin, where each of the functions it registers into the registry will also register a persistable state definition.

Persistable state ID should be a const exported on plugin contract.

## We have to avoid shared persistable state

So we have to do two things:

- Always call a migration function for nested state.
- Maintain state boundaries along plugin boundaries. Do not allow VisualizeEmbeddableInput to migrate data on EmbeddableInput. Don't even give it access to this state at all.
As long as state ownership is isolated, we can do something like this:

```ts
const embeddableMigrator: PersistableStateMigrationFn (state: { type: string; input: unknown }, version: string): State {
return {
...migrateInputToLatest(state.input),
enhancements: {
ppisljar marked this conversation as resolved.
Show resolved Hide resolved
...state.input.enhancements.map(enhancement =>
persistableStateMigrations.get(enhancement).migrate(state.input.enhancements[extension], version)),
},
specializedState: persistableStateMigrations.get(state.type).migrate(state.input.specializedState, version)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be curious to see how the conversion from deserialized state to EmbeddableInput will go, and whether we can continue to assume users of Embeddables can still access things like:

const input: VisualizeEmbeddableInput = visEmbeddable.getInput();
console.log(input.vis.type);

vs

const input: VisualizeEmbeddableInput = visEmbeddable.getInput();
console.log(input.specializedState.visualize.type);

If we were able to have the serialized representation different from the deserialized representation, we could do this more formally.

const input: VisualizeEmbeddableInput = visEmbeddable.getInput();
console.log(input.vis.type);

const serializedState = visEmbeddablePlugin.beforeSave(input);
console.log(serializedState.specializedState.visualize.type);

// save to disk
// load from disk

const deserializedState = visEmbeddablePlugin.afterLoad(serializedState);
console.log(deserializedState.vis.type);

But the way the types are right now, deserialized and serialized state are the same. Maybe that layer doesn't have to be part of this system, but if not, I'm not sure where it'd go. 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no serialization/deserialization happening inside this service

we are only working with Serializable state (which means a state that can be serialized, so no functions, circular references, .....)

this is kind of in between the StateInstance (EmbeddableInput instance) and SerializedState (for example json stored in database, or url string)

i dont know enough about embeddables, but for visualize state this goes something like this:

vis --> instance of Vis class, represents the state of each visualization
vis.serialize() --> returns VisSerializable
persistableStateService.beforeSave('visualization', vis.serialize()) --> returns VisSerializable and references array (the actual type of the object should not change, we just extracted the references)
savedVisLoader.save(id, persistableStateService.beforeSave('visualization', vis.serialize())) --> saves to a saved object (i think the save method actually doesn't exist (yet, or in this form))

so with embeddable i could see something like EmbeddableInput, SerializableEmbeddableInput (we need some utility to convert between those) and the later is the one PersistableStatePlugin is interested in (and can be later serialized and stored)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should still have an answer for this for Embeddables to make sure the system supports our needs. Maybe @Dosant or @streamich can work through how this would work with Embeddables.

I think we should touch on what to do when your state is extended like SpecializedState extends BaseState. We can mention it in a generalized sense. Like the enhancements pattern, I think the state registrator will have to explicitly support this. I mention above, perhaps we could solve this and the enhancements pattern with the same method, using an enhancements key on the state?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Dosant can you take a look into this ?

}
}
```

The key here is that we have to separate out the state owned by VisualizeEmbeddableInput and that owned by EmbeddableInput. If we allow them both access to the full input (VisualizeEmbeddableInput extends EmbeddableInput), there is the possibility for key clashes. For instance:

```ts
// We start out with the base class having an attribute of `title`.
interface EmbeddableInputV1 {
title: string;
}

// `Title` is taken by the base class, so Visualize plugin adds `defaultTitle`.
interface VisualizeEmbeddableInputV1 implements EmbeddableInputV1 {
defaultTitle: string;
}

// In a future version, EmbeddableInput author decides to rename `title` => `defaultTitle`.
// They don't know about `VisualizeEmbeddableInput` so don't realize there is going to be
// a key collision.
interface EmbeddableInputV2 {
defaultTitle: string;
}
```

It's probably a rare occurrence, but it's also very risky. EmbeddableInput thinks it owns defaultTitle. Let's say it decides to change the name yet again, so in V3 changes it to customPanelTitle and removes defaultTitle. Let's also say VisualizeEmbeddable author doesn't add a migration for V2 or V3. A user migrates to V3, VisualizeEmbeddable just had its defaultTitle state wiped out unknowingly (types will still say it's a required parameters). This has the potential to permanently break saved objects with no way to undo the migration.

# What saved object migrations

With saved object migrations (or any other migration of potentially high amount of state objects) currently we try to detect when a migration needs to be performed. For example if there was no change in specific saved object between minors we will not try to read or write those objects from/to elasticsearch.

With extention points to which 3rd party developers might register their own items which expose state that is not possible. For example dashboard saved object might contain state from any embeddable. As we don't know about all those embeddables we need to always fetch all saved objects and run migrations on all of them.
If expecting to handle large amount of state objects you should always deep compare input and output state and filter out objects without changes to avoid to many updates in the database.
Comment on lines +279 to +280
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure to really follow here. From what I understood, this persistable state service / registry was not directly linked to SO migrations (see # Server or Client ? part), and actual migration of the underlying data would only be performed during object access? Why would we need to fetch/migrate everything during SO migration then?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we were to use this for saved object migrations: with saved object migrations we want to know if there is a migration to perform or not. however this is getting harder and harder because of deep dependency trees:

  • do we have a migration for dashboard saved objects for 7.9.1 ?:
    • do we have migration for base embeddable input for 7.9.1 ?
      • do we need to migrate filters ?
      • do we need to migrate query ?
      • do we need to migrate timerange ?
    • do we have migration for any of the embeddable types for 7.9.1 ?
      • lens embeddable:
        • do we need to migrate lens state ?
        • do we need to migrate expression ?
          • does any expression function need to be migrated ?
      • visualize embeddable:
        • do we have migration for search source ?
        • do we have migration for index pattern ?
        • do we have migration for any vis type ?
          • once any vis type starts storing expression:
            • is there a migration required for expression?
              • is there migration required for any expression function ?
    • do we have migration for any enhancement ?
      • drilldowns:
        • do we need to migrate filters ?

you can see how our state is deeply nested with various plugins owning various parts of it. Its also not showing everything (its oversimplified). so figuring out if there is a migration required for specific version (7.9.1) is a hard problem.

also important to note: this is a hard problem with or without global registry. actually without global registry this becomes even harder problem to solve imo, as we don't have registries for a lot of parts of the above dashboard state.



# Full look into a complex example: saved dashboards

saved dashboard state looks something like this:
```ts
{
panels: [{
...embeddable,
enhancements: {
drilldowns: {
....drilldownStateForThisEmbeddable
}
}
}],
}
```

where any plugin can add to enhancements a property name matching its state id with value being that state.
also any embeddable can extend the base embeddable input type.

dashboard needs to register a persistable state service with migrate function looking something like this:
```ts
const migrate = (state: unknown, version: string) => {
// do the internal state migration like state.title etc (owned by dashboard)

// and then migrate panels (state not owned by dashboard)
state.panels = state.panels.map(p => baseEmbeddablePesistableStateDefinition.migrate(p, version));
}
```

all panels extend base embeddable, which would register its baseEmbeddablePErsistableState with migrate function like this:

```ts
const migrate = (state: unknown, version: string) => {
// do the internal state migration like state.title etc (owned by base embeddable)

// then migrate enhancements where every key represents a state not owned by embeddable
Object.keys(state.enhancements).forEach(k => {
state.enhancements[k] = persistableStateRegistry.get(k).migrate(state.enhancements[k], version);
}

// migrate the actual embeddable type
// here we run into an issue as base embeddable input and specific embeddable input should not collide (thru any version) ...
// this is mentioned before "We have to avoid shared persistable state" topic
// we should make all embeddables provide their custom inputs under a base property to prevent that
return persistableStateRegistry.get(state.type).migrate(state, version),
Comment on lines +326 to +327
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going back on the

A plugin could have a single stateType, for example visualizations plugin has a savedVis state, so it use visualizations-savedVis as id. Other plugins might have multiple state types they want to register. Lets say that we introduce a new state type to visualizations plugin called visState we would use visualizations-visState as id.

part here. I guess that's not the case atm (types are not prefixed with the pluginId, are they). How will 'premigration' of the existing persisted data be performed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very good point, i think we need to allow migrate function to return different state id. so when i would be migrating the current dashboard enhancements (enhancement.drilldown) i can return new state id and change that to enhancements.drilldowns_drilldownState

}

```


# Drawbacks

- teaching impact: everyone storing state from belonging to another plugin will need to remember to use this service when saving and loading state.
- this proposal uses a different pattern than the current SO migration system which is having SOs register a migration function per version, where this system has users register a single function across all versions. That means consumer doesn't care what version his state is, he can assume he will always get the latest state out of the migrate function.

# Adoption strategy

First app arch team will add persistable state definitions for saved visualizations and saved searches (vis state, search source), expressions and base embeddables. Adoption by other teams can happen gradually.

# How we teach this

If state id is known at compile time we should rather import the correct utilities directly from registrator plugin. As using the registry can hide dependencies between plugins when possible plugins should directly depend on plugins providing the state they want to store.

# Unresolved questions