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

Updates to state provider deep dive #549

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open

Conversation

trmartin4
Copy link
Member

@trmartin4 trmartin4 commented Feb 22, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-14197
https://bitwarden.atlassian.net/browse/PM-19220
https://bitwarden.atlassian.net/browse/PM-19218
https://bitwarden.atlassian.net/browse/PM-19219

📔 Objective

  • Moved the section on update out of the "Advanced Usage" section, since we are going to be encouraging teams to use it to avoid unnecessary I/O for state updates (see Reduce desktop disk writes clients#13271 for an example). Before we start asking teams to pay more attention to this, I wanted to make sure it had a good place in the docs.
  • Re-ordered the definitions to move ActiveUserState to the bottom, since it is no longer recommended.
  • Removed some of the references to migrations, since they are mostly complete.
  • Documented cleanupDelayMs option behavior when it's set to 0.
  • Documented that we discourage firstValueFrom(state$) directly after update.
  • Documented that state$ does not guarantee an emission on same value updates.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation
    team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed
    issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

Copy link

cloudflare-workers-and-pages bot commented Feb 22, 2025

Deploying contributing-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: fbd3861
Status: ✅  Deploy successful!
Preview URL: https://157b4563.contributing-docs.pages.dev
Branch Preview URL: https://state-provider-updates.contributing-docs.pages.dev

View logs

Copy link

github-actions bot commented Feb 22, 2025

Logo
Checkmarx One – Scan Summary & Detailsa1ee5c2c-3c93-4695-a5f8-952eb80f3bfe

Great job, no security vulnerabilities found in this Pull Request

@trmartin4
Copy link
Member Author

@justindbaur Do you think that it's OK to remove the section on Migration and the details on how StateService definitions map to StateProvider definitions? I don't want to clutter up the docs with things that teams aren't using today, but I also see the value in having it for reference in the future. Perhaps a sub-page? Or are you OK with keeping it in git history? Or would you prefer to leave it as-is?

@trmartin4 trmartin4 marked this pull request as ready for review February 22, 2025 17:36
@trmartin4 trmartin4 requested a review from a team as a code owner February 22, 2025 17:36
@justindbaur
Copy link
Member

justindbaur commented Feb 24, 2025

@trmartin4 Thank you for this, I had thought about adding some more docs regarding update now that we are going to recommend it more.

I do think having a brief blurb about state migrations is good as teams will still occasionally have to do them. We don't need to keep around the state service to state provider mappings though. Since state migrations shouldn't change after being written though it could be useful to just defer to here for any examples and we don't need one here in the docs.

As for shouldUpdate I think it might be time for a new example(s). What we are about to ask people is to do more than just avoid saving null when it's already null and instead asking them to avoid saving the same value over and over. So I was thinking about adding something like the following:

=============

A simple example using shouldUpdate on simple JavaScript primitive types that work nicely with the strict equal operator (===).

const USES_KEYCONNECTOR: UserKeyDefinition<boolean> = ...;

async setUsesKeyConnector(value: boolean, userId: UserId) {
  // Only do the update if the current value saved in state
  // differs in equality of the incoming value.
  await this.stateProvider.getUser(userId, USES_KEYCONNECTOR).update(
    currentValue => currentValue !== value
  );
}

Implementing a custom equality operator

type Cipher = { id: string, username: string, password: string, revisionDate: Date };
const LAST_USED_CIPHER: UserKeyDefinition<Cipher> = ...;

async setLastUsedCipher(lastUsedCipher: Cipher | null, userId: UserId) {
  await this.stateProvider.getUser(userId, LAST_USED_CIPHER).update(
    currentValue => !this.areEqual(currentValue, lastUsedCipher)
  );
}

areEqual(a: Cipher | null, b: Cipher | null) {
  if (a == null) {
    return b == null;
  }

  if (b == null) {
    return false;
  }

  // Option one - Full equality, comparing every property for value equality
  return a.id === b.id && 
    a.username === b.username && 
    a.password === b.password &&
    a.revisionDate === b.revisionDate;

  // Option two - Partial equality based on requirement that any update would
  // bump the revision date.
  return a.id === b.id && a.revisionDate === b.revisionDate;
}

It's important that if you implement an equality function that you then negate the output of that function for use in shouldUpdate since you will want to go through the update when they are NOT the same value.

=============

@trmartin4
Copy link
Member Author

@justindbaur I addressed your concerns, if you don't mind taking a look.

@trmartin4
Copy link
Member Author

@justindbaur added today's feedback 👍

Copy link
Member

@justindbaur justindbaur left a comment

Choose a reason for hiding this comment

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

Just a few comments, thank you so much!

- [`EnvironmentService`](https://github.com/bitwarden/clients/pull/7621)
- [Org Keys](https://github.com/bitwarden/clients/pull/7521)
Use of `firstValueFrom()` should be avoided. If you find yourself trying to use `firstValueFrom()`,
consider propagating the underlying observable instead of leaving reactivity. :::
Copy link
Member

Choose a reason for hiding this comment

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

Missing end to the warning block?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oops! Fixed in fbd3861 (#549).

@trmartin4 trmartin4 requested a review from justindbaur March 17, 2025 19:21
Copy link
Member

@justindbaur justindbaur left a comment

Choose a reason for hiding this comment

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

Thank you so much

@MGibson1 MGibson1 self-requested a review March 20, 2025 15:34
Copy link
Member

@MGibson1 MGibson1 left a comment

Choose a reason for hiding this comment

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

A few tweaks, but the biggest one is an omission from the original -- async updates.

Comment on lines +151 to +156
`StateProvider` is an injectable service that includes 3 methods for getting state. These three
methods are helpers for invoking their more modular siblings `SingleUserStateProvider.get`,
`GlobalStateProvider.get`, and `DerivedStateProvider`. These siblings can all be injected into your
service as well. If you prefer thin dependencies over the slightly larger changeset required, you
can absolutely make use of the more targeted providers. `StateProvider` has the following type
definition (aliasing the targeted providers):
Copy link
Member

Choose a reason for hiding this comment

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

Why remove active user state from here, but not the

#### `KeyDefinition` and `UserKeyDefinition`

section?

It's also referenced below. Let's acknowledge its existence here, but introduce it as a deprecated method that attempted to resolve the state of the active user, but produces race conditions.

getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>;
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T>;
getDerived<TFrom, TTo, TDeps>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependenciess: TDeps,
);
// Deprecated, do not use.
Copy link
Member

Choose a reason for hiding this comment

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

observation, I don't really think this needs to change here, but this isn't yet deprecated in the interface.

Comment on lines 206 to +207
The `state$` property provides you with an `Observable<T>` that can be subscribed to.
`ActiveUserState<T>.state$` will emit for the following reasons:

- The active user changes.
- The chosen storage location emits an update to the key defined by `KeyDefinition`. This can occur
for any reason including:
- A `SingleUserState<T>` method pointing at the same `UserKeyDefinition` as `ActiveUserState` and
pointing at the user that is active that had `update` called
- Someone updates the key directly on the underlying storage service _(please don't do this)_
`GlobalState<T>.state$` will emit when the chosen storage location emits an update to the state
Copy link
Member

Choose a reason for hiding this comment

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

issue Let's maintain the T | null type parameters for state. This would be true of roughtly every type parameter in this file.


### `GlobalState<T>`
#### `SingleUserState<T>`
Copy link
Member

Choose a reason for hiding this comment

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

issue This section should mention combinedState$ somewhere


type ExpectedAccountState = { myUserData: string };
`ActiveUserState` has race condition problems. Do not use it for updates and consider transitioning
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
`ActiveUserState` has race condition problems. Do not use it for updates and consider transitioning
`ActiveUserState` has race condition problems. Do not add usages and consider transitioning

Comment on lines +268 to +271
If you do need to obtain the result of an update in a non-reactive way, you should use the result
returned from the `update()` method. This should be used instead of immediately re-requesting the
value through `firstValueFrom()`. The `update()` will return the value that will be persisted to
state, after any `shouldUpdate()` filters are applied.
Copy link
Member

Choose a reason for hiding this comment

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

duplicative

Suggested change
If you do need to obtain the result of an update in a non-reactive way, you should use the result
returned from the `update()` method. This should be used instead of immediately re-requesting the
value through `firstValueFrom()`. The `update()` will return the value that will be persisted to
state, after any `shouldUpdate()` filters are applied.
If you do need to obtain the result of an update in a non-reactive way, you should use the result
returned from the `update()` method. The `update()` will return the value that will be persisted to
state, after any `shouldUpdate()` filters are applied.

- The use of `firstValueFrom` with no `timeout`. Behind the scenes we enforce that the observable
given to `combineLatestWith` will emit a value in a timely manner, in this case a `1000ms`
timeout, but that number is configurable through the `msTimeout` option.
- We don't guarantee that your `updateState` function is called the instant that the `update` method
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
- We don't guarantee that your `updateState` function is called the instant that the `update` method
- We don't guarantee that your `updateState` callback is called the instant that the `update` method

},
);
```

Copy link
Member

Choose a reason for hiding this comment

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

issue This section is missing a large use case for async updates and rx transform pipelines. combineLatestWith can be used to handle updates where the new value depends on either async code or you'd prefer to handle generation of a new value in an observable transform flow.

    const state = this.stateProvider.get(userId, SavedFiltersStateDefinition);

    const transform: OperatorFunction<any, any> = pipe(
      // perform some transforms
      map((value) => value),
    )

    async function transformAsync<T>(value: T) {
      return Promise.resolve(value);
    }

    await state.update((_, newState) => newState, {
      // Actual processing to generate the new state is done here
      combineLatestWith: state.state$.pipe(
        mergeMap(async (old) => {
          return await transformAsync(old);
        }),
        transform,
      ),
      shouldUpdate: (oldState, newState) => !areEqual(oldState, newState),
    })


The second instance of calling `set` will not log a changed event. As a result, the `state$` relying
on this value will not emit. Due to nuances like this, using a `StateProvider` as an event stream is
discouraged, and we recommend using `MessageSender` for events that you always want sent to
Copy link
Member

Choose a reason for hiding this comment

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

Do we have a doc to link here? If not, please link to a github permalink until we can create one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants