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

RJS-2636: Add progress notifications and tests #6743

Merged
merged 73 commits into from
Jul 5, 2024

Conversation

gagik
Copy link
Contributor

@gagik gagik commented Jun 21, 2024

What, How & Why?

Adds progress notifications for flexible sync and tests for it.

realm.syncSession?.addProgressNotification(
  ProgressDirection.Upload,
  ProgressMode.ReportIndefinitely,
  (estimate) => console.log(`progress: ${estimate}/1.0`)
);

This closes #6256

@realm/devdocs

☑️ ToDos

  • 📝 Changelog entry
  • 📝 Compatibility label is updated or copied from previous entry
  • 📝 Update COMPATIBILITY.md
  • 🚦 Tests
  • 📦 Updated internal package version in consuming package.jsons (if updating internal packages)
  • 📱 Check the React Native/other sample apps work if necessary
  • 💥 Breaking label has been applied or is not necessary

@cla-bot cla-bot bot added the cla: yes label Jun 21, 2024
@gagik gagik changed the title Add progress notifications and tests RJS-2636: Add progress notifications and tests Jun 21, 2024
@@ -49,6 +50,7 @@ const PersonForSyncSchema: Realm.ObjectSchema = {
},
};

// TODO: Fix this.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is unrelated to this PR, I can create a follow-up issue but waitForConnectionState seems to be a complete noop right now, may mean some tests either don't need it or are meaningless.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

integration-tests/tests/src/tests/sync/flexible.ts Outdated Show resolved Hide resolved
integration-tests/tests/src/tests/sync/flexible.ts Outdated Show resolved Hide resolved
const progressCallback = spy();
await Realm.open(config).progress(progressCallback);

expect(progressCallback.called).is.true;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not going to spend much time refactoring the other tests since they related to the deprecated method, just thought this was a nice opportunity for sinon (also just timeout).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, there's a sinon-chai integration and I will switch to that soon, maybe in a different PR if there's not enough time for now

@gagik gagik marked this pull request as ready for review June 24, 2024 11:09
@gagik gagik marked this pull request as draft June 25, 2024 06:48
@gagik gagik force-pushed the gagik/progress-notifications branch from 945ca7a to 1908727 Compare June 26, 2024 07:39
@gagik gagik marked this pull request as ready for review June 26, 2024 09:10
@gagik
Copy link
Contributor Author

gagik commented Jun 26, 2024

I am still battling some CI issues but I figure it may be worth requesting some reviews for now.

@@ -84,6 +84,8 @@
"@types/chai-as-promised": "^7.1.5",
"@types/jsrsasign": "^10.5.4",
"@types/mocha": "^10.0.0",
"@types/sinon": "^9.0.5",
"assert": "^2.1.0",
Copy link
Contributor Author

@gagik gagik Jun 26, 2024

Choose a reason for hiding this comment

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

This polyfill seems necessary for electron...

Copy link
Member

Choose a reason for hiding this comment

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

I'd like more context on this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It seemed necessary for sinon but I had since then changed the version to something which has more compatibility so it may not be needed anymore

@@ -167,18 +175,31 @@ export class ProgressRealmPromise implements Promise<Realm> {
*/
progress(callback: ProgressNotificationCallback): this {
this.listeners.add(callback);
if (callback.length === 1) {
const estimateCallback = callback as DynamicProgressNotificationCallback;
estimateCallback(1.0);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

since this behavior is sort of expected from previous implementation, I figured this was best equivalent.

@gagik
Copy link
Contributor Author

gagik commented Jun 26, 2024

Okay, I am 99% certain that last failing tests is a result of GHA caching, and I tried to delete the cache manually many times for this branch/PR but seems to keep using it, @kraenhansen may have a better idea of what is going on here... but will have to leave it at that since this now 62 commits deep into making it fully green 😄 At least it does not seem flaky and is somewhat reasonable in its underlying implementation.

Will leave it at this point since there's no chance to have a proper reviews but feel free to continue from here to get it merged. I'd say it is pretty complete and although tests are far from ideal, there is also no clear behavior expectations yet and much of it is just up to core.

Should be good to merge beyond any review comments you can have. Feel free to get in touch if needed in next few days.

Copy link
Member

@kneth kneth left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Member

@elle-j elle-j left a comment

Choose a reason for hiding this comment

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

Comments from a first pass here, looks really nice 🙂

Comment on lines 87 to 88
/** @internal */
private token = 0;
Copy link
Member

@elle-j elle-j Jun 27, 2024

Choose a reason for hiding this comment

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

  1. Is 0 being used as an "unset" indicator here? It could possibly make it clearer if we used e.g. -1 (as the token in Core uint) or null.
    • We should also have the number type be binding.Int64 rather than number. (See e.g. this)
  2. Could we add a short sentence to the internal docs here mentioning that it's used for unregistering the notifier?

// TODO: Consider storing the token returned here to unregister when the task gets cancelled,
// if for some reason, that doesn't happen internally
this.task.registerDownloadProgressNotifier(this.emitProgress);
if (this.listeners.size > 0) {
Copy link
Member

@elle-j elle-j Jun 27, 2024

Choose a reason for hiding this comment

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

Isn't it possible for a listener to be added (via progress()) after this code has been executed? If so, then I think this guard should probably be removed. (Invoking the listeners is still guarded by it's size here.)

Comment on lines 148 to 151
if (this.token !== 0) {
this.task?.unregisterDownloadProgressNotifier(this.token);
}
Copy link
Member

Choose a reason for hiding this comment

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

Should we reset the token after the Core call here as well?

@@ -153,6 +160,7 @@ export class ProgressRealmPromise implements Promise<Realm> {
cancel(): void {
this.cancelAndResetTask();
this.timeoutPromise?.cancel();
this.task?.unregisterDownloadProgressNotifier(this.token);
Copy link
Member

Choose a reason for hiding this comment

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

We could guard for the "unset" token value here.

@@ -167,18 +175,31 @@ export class ProgressRealmPromise implements Promise<Realm> {
*/
progress(callback: ProgressNotificationCallback): this {
Copy link
Member

Choose a reason for hiding this comment

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

The API docs for this function needs an update 🙂

@@ -93,6 +109,21 @@ function toBindingDirection(direction: ProgressDirection) {
}
}

function isEstimateProgressNotification(cb: ProgressNotificationCallback): cb is DynamicProgressNotificationCallback {
Copy link
Member

Choose a reason for hiding this comment

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

  1. I believe a Callback postfix is missing from the name.
  2. Could we use the fully spelled out name callback for increased consistency?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

agree with the callback, these bits of code are a mix of mine and largely @kneth so he may want to weigh in as well

Comment on lines 116 to 139
function toBindingProgressNotificationCallback(cb: ProgressNotificationCallback) {
if (isEstimateProgressNotification(cb)) {
return (transferredBytes: binding.Int64, transferrableBytes: binding.Int64, progressEstimate: number) =>
cb(progressEstimate);
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (transferredBytes: binding.Int64, transferrableBytes: binding.Int64, _: number) =>
cb(transferrableBytes, transferrableBytes);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

  • Nit: Argument name
  • The 2nd callback was accidentally called without transferredBytes.
Suggested change
function toBindingProgressNotificationCallback(cb: ProgressNotificationCallback) {
if (isEstimateProgressNotification(cb)) {
return (transferredBytes: binding.Int64, transferrableBytes: binding.Int64, progressEstimate: number) =>
cb(progressEstimate);
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (transferredBytes: binding.Int64, transferrableBytes: binding.Int64, _: number) =>
cb(transferrableBytes, transferrableBytes);
}
}
function toBindingProgressNotificationCallback(callback: ProgressNotificationCallback) {
if (isEstimateProgressNotification(callback)) {
return (_: binding.Int64, __: binding.Int64, progressEstimate: number) => callback(progressEstimate);
} else {
return (transferredBytes: binding.Int64, transferrableBytes: binding.Int64, _: number) =>
callback(transferredBytes, transferrableBytes);
}
}

Comment on lines -254 to +299
(transferred, transferable) => callback(Number(transferred), Number(transferable)),
toBindingProgressNotificationCallback(callback),
Copy link
Member

Choose a reason for hiding this comment

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

💪

* @param callback - Called with the following arguments:
* 1. `transferred`: The current number of bytes already transferred
* 2. `transferable`: The total number of transferable bytes (the number of bytes already transferred plus the number of bytes pending transfer)
* @param callback - see {@link DynamicProgressNotificationCallback} and {@link PartitionBasedSyncProgressNotificationCallback}
Copy link
Member

@elle-j elle-j Jun 27, 2024

Choose a reason for hiding this comment

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

Perhaps something similar to this could suffice (mainly because the type itself exists by the parameter; so similarly to e.g. direction, we don't refer to the ProgressDirectionss available in the doc description here 👍 ). (Although it looks like we do for mode so I see that it's not really consistent 😛 )

Suggested change
* @param callback - see {@link DynamicProgressNotificationCallback} and {@link PartitionBasedSyncProgressNotificationCallback}
* @param callback - The function to call when the progress is updated.

Comment on lines 480 to 485
if (this.config.flexible) {
assert(
isEstimateProgressNotification(callback),
`For flexible sync the callback can only take one argument - got ${callback.length} arguments`,
);
}
Copy link
Member

@elle-j elle-j Jun 27, 2024

Choose a reason for hiding this comment

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

I think this may be a breaking change, since it has still been possible to call the API, and we haven't documented that it should have thrown before. Hmm, could we warn here instead?

Copy link
Member

Choose a reason for hiding this comment

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

I agree - this feels more breaking than a deprecation (which might be okay? but should be explicit and released accordingly) if it actually throws instead of simply warning.

Copy link
Member

Choose a reason for hiding this comment

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

We could update it to e.g.:

if (this.config.flexible && !isEstimateProgressNotificationCallback(callback)) {
  /* eslint-disable-next-line no-console */
  console.warn("Deprecated progress notifier used. Please use 'EstimateProgressNotificationCallback' which accepts the estimate as the only argument.");
}

But I think we could even skip the warning, since the warning is part of the deprecation in the docs. (We don't usually warn for other deprecations.)

For now I've updated this to just assert that it's a function.

@@ -53,13 +53,29 @@ export enum ProgressMode {
ForCurrentlyOutstandingWork = "forCurrentlyOutstandingWork",
}

export type ProgressNotificationCallback =
/** @deprecated */
export type PartitionBasedSyncProgressNotificationCallback =
Copy link
Member

Choose a reason for hiding this comment

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

For the new public types that got introduced, they also need to be exported to users similar to this.

For deprecated ones such as the PBS-specific one, we list them below this line.

Comment on lines 2087 to 2091
before(async function (this: UserContext) {
// TODO: Investigate this.
// Use a new user for this test suite as not doing so causes some session issues in other tests
this.user = await this.app.logIn(Credentials.anonymous(false));
});
Copy link
Member

@kraenhansen kraenhansen Jun 28, 2024

Choose a reason for hiding this comment

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

Are you aware of the authenticateUserBefore hook? If its important to avoid reusing, you could patch the function to take a boolean argument:

this.user = this.app.currentUser || (await this.app.logIn(Credentials.anonymous()));

Comment on lines 2092 to 2102
beforeEach(async function (this: RealmContext & UserContext) {
// @ts-expect-error Using an internal API
this.realm = new Realm({
schema: [Person, Dog],
sync: {
flexible: true,
user: this.user,
_sessionStopPolicy: SessionStopPolicy.Immediately,
},
});
});
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason you're not using openRealmBeforeEach here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, I was moving back and forth between using that or my own as I had other cleanup hooks to check different things but now it seems like this can be fully covered byopenRealmBeforeEach, feel free to change it.

Comment on lines 2104 to 2112
it("only estimate callback is allowed", async function (this: RealmContext) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
const callback = (estimate: number) => {};
this.realm.syncSession?.addProgressNotification(
Realm.ProgressDirection.Download,
Realm.ProgressMode.ForCurrentlyOutstandingWork,
callback,
);
});
Copy link
Member

Choose a reason for hiding this comment

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

What is this testing specifically? Passing a single argument arrow function as callback?

Copy link
Contributor Author

@gagik gagik Jun 28, 2024

Choose a reason for hiding this comment

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

Yes, this has to do with deprecated API vs non-deprecated. Specifically, with sync 2 argument arrow function will error with flexible sync

@gagik
Copy link
Contributor Author

gagik commented Jun 28, 2024

Just tried to reply to some of the comments that may have been hard to guess without my input. Beyond that, going to be away so feel free to go ahead with what seems best 😄

Copy link
Member

@elle-j elle-j left a comment

Choose a reason for hiding this comment

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

Great to see many tests here 🙂 Left some suggestions and thoughts

);
});

it("old callback style is not allowed", async function (this: RealmContext) {
Copy link
Member

Choose a reason for hiding this comment

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

This should be updated (to be allowed) once the assertion from addProgressNotification() is removed in order to not break the API 👍

Comment on lines +2155 to +2125
await this.realm.syncSession?.uploadAllLocalChanges();
await this.realm.syncSession?.downloadAllServerChanges();
Copy link
Member

Choose a reason for hiding this comment

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

As this combo is used frequently, we could create a small helper.

Comment on lines 2158 to 2160
// TODO: This callback should not be called at this stage but seems flakey
// and gets called with 1.0 at times, likely because of a race condition.
expect(callback.notCalled || callback.calledWith(1.0)).is.true;
Copy link
Member

Choose a reason for hiding this comment

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

Wondering if we should still remove the callback.calledWith(1.0) though.

Suggested change
// TODO: This callback should not be called at this stage but seems flakey
// and gets called with 1.0 at times, likely because of a race condition.
expect(callback.notCalled || callback.calledWith(1.0)).is.true;
expect(callback.notCalled || callback.calledWith(1.0)).to.be.true;


// TODO: Find a way to fix
// There should be at least one point where the progress is not yet finished.
// expect(callback.args.find(([estimate]) => estimate < 1)).to.not.be.undefined;
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
// expect(callback.args.find(([estimate]) => estimate < 1)).to.not.be.undefined;
// expect(callback.args.some(([estimate]) => estimate < 1)).to.be.true;

I'm unsure why your code there didn't work?

There seem to be another way with sinon as well:

expect(callback.calledWith(sinon.match(estimate => estimate < 1))).to.be.true;

A question regarding the comment though, is it true that we should always expect a number < 1? Or is it possible that for small uploads to sometimes only report the 1?

// There should be at least one point where the progress is not yet finished.
// expect(callback.args.find(([estimate]) => estimate < 1)).to.not.be.undefined;

expect(callback.withArgs(1.0)).called;
Copy link
Member

Choose a reason for hiding this comment

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

Unsure if withArgs() is the correct usage here since that seems to create a new spy (according to their API docs). If using that, you probably want to put the called onto the spy instead (similar to the above where callback.notCalled is used):

expect(callback.withArgs(1.0).called).to.be.true

However, there are also these APIs:

callback.calledWith(..)
callback.calledWithExactly(..)

import { buildAppConfig } from "../../utils/build-app-config";
import { spy } from "sinon";
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Can move up to the 3rd party imports 🙂

expect(progressCalled).to.be.true;

const progressCallback = spy();
await Realm.open(config).progress(progressCallback);
Copy link
Member

Choose a reason for hiding this comment

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

👍

CHANGELOG.md Outdated
Comment on lines 4 to 18
* The callback for `realm.syncSession.addProgressNotification` will only take a single argument in the future: `estimate` (which supports both partition based and flexible sync). The old callback functionality is deprecated and will be removed. The estimate is roughly equivalent to an estimated value of `transferred / transferable` in the deprecated partition-based sync callback. ([#6256](https://github.com/realm/realm-js/issues/6256))
```ts
/** New callback which supports both flexible and partition-based sync */
realm.syncSession.addProgressNotification(
Realm.ProgressDirection.Upload,
ProgressMode.ReportIndefinitely,
(estimate) => console.log(`progress estimate: ${estimate}/1.0`),
);
/** @deprecated */
realm.syncSession.addProgressNotification(
Realm.ProgressDirection.Upload,
ProgressMode.ReportIndefinitely,
(transferred, transferable) => console.log(`progress: ${(transferred / transferable)}/1.0`),
);
```
Copy link
Member

Choose a reason for hiding this comment

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

Nice info 🙂 I'm wondering if the info about the new API is better served along with the Enhancement entry and refer to that one instead? We could still mention deprecated API in the current section.

CHANGELOG.md Outdated

### Enhancements
* None
* Added progress notifications support for flexible sync using a new callback argument. ([#6256](https://github.com/realm/realm-js/issues/6256))
```ts
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
```ts
```typescript

CHANGELOG.md Outdated
* None
* Added progress notifications support for flexible sync using a new callback argument. ([#6256](https://github.com/realm/realm-js/issues/6256))
```ts
realm.syncSession.addProgressNotification(
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
realm.syncSession.addProgressNotification(
realm.syncSession?.addProgressNotification(

@elle-j
Copy link
Member

elle-j commented Jul 4, 2024

Note on Android tests: Still experiencing signature mismatch.

(Reference)

@kraenhansen
Copy link
Member

@elle-j with #6769 merged, I'd expect the Android tests to run if this branch is rebased.

@elle-j elle-j force-pushed the gagik/progress-notifications branch from 3714a5d to 6f3811d Compare July 5, 2024 06:50
@elle-j elle-j merged commit b4fd42b into main Jul 5, 2024
34 checks passed
@elle-j elle-j deleted the gagik/progress-notifications branch July 5, 2024 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Adopt new progress notification callback
4 participants