Skip to content
Merged
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
5 changes: 3 additions & 2 deletions common/api/core-backend.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4340,6 +4340,8 @@ export namespace IModelDb {
}
// @preview
export class Views {
// @internal
[_close](): void;
// @internal
constructor(_iModel: IModelDb);
// @beta (undocumented)
Expand All @@ -4359,7 +4361,7 @@ export namespace IModelDb {
saveThumbnail(viewDefinitionId: Id64String, thumbnail: ThumbnailProps): number;
// @deprecated
setDefaultViewId(viewId: Id64String): void;
// @beta (undocumented)
// @beta
get viewStore(): ViewStore.CloudAccess;
set viewStore(viewStore: ViewStore.CloudAccess);
}
Expand Down Expand Up @@ -7811,7 +7813,6 @@ export class V2CheckpointManager {
dbName: string;
container: CloudSqlite.CloudContainer | undefined;
}>;
// (undocumented)
static cleanup(): void;
// (undocumented)
static readonly cloudCacheName = "Checkpoints";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/build-tools",
"comment": "Reduced the mocha-reporter handle-leak detection timeout for non-Chrome Node test runs from 30 to 10 seconds; Chrome remains at 30 seconds.",
"type": "none"
}
],
"packageName": "@itwin/build-tools"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-backend",
"comment": "IModelDb.close() now closes its ViewStore, and IModelHost.shutdown() now also disconnects V2 checkpoint containers. Separately, non-public containers with token-refresh enabled (`tokenRefreshSeconds > 0`, the default) keep the process alive until disconnected - disconnect any you manage outside `CloudSqlite.CloudCaches` before exiting.",
"type": "none"
}
],
"packageName": "@itwin/core-backend"
}
4 changes: 3 additions & 1 deletion core/backend/src/CheckpointManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ export class V2CheckpointManager {
return cloudCachePath;
}

/* only used by tests that reset the state of the v2CheckpointManager. all dbs should be closed before calling this function. */
/** Disconnects all checkpoint containers and resets the state of the V2CheckpointManager. All dbs should be closed before calling this function.
* Called automatically on IModelHost shutdown; also used directly by tests.
*/
public static cleanup(): void {
for (const [_, value] of this.containers.entries()) {
if (value.isConnected)
Expand Down
28 changes: 20 additions & 8 deletions core/backend/src/CloudSqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export namespace CloudSqlite {
interface CloudContainerInternal extends CloudContainer {
timer?: NodeJS.Timeout;
refreshPromise?: Promise<void>;
refreshGeneration: number;
lockExpireSeconds: number;
writeLockHeldBy?: string;
}
Expand Down Expand Up @@ -101,14 +102,18 @@ export namespace CloudSqlite {
// when the object is cloned (e.g. when included in an exception across processes).
addHiddenProperty(container, "timer");
addHiddenProperty(container, "refreshPromise");
addHiddenProperty(container, "refreshGeneration", 0);

const refreshSeconds = (undefined !== args.tokenRefreshSeconds) ? args.tokenRefreshSeconds : 60 * 60; // default is 1 hour
container.lockExpireSeconds = args.lockExpireSeconds ?? 60 * 60; // default is 1 hour

// don't refresh tokens for public containers or if refreshSeconds<=0
if (!args.isPublic && refreshSeconds > 0) {
const tokenProps = { baseUri: args.baseUri, containerId: args.containerId, accessLevel: args.accessLevel };
const doRefresh = async () => {
// `generation` is bumped on every connect/disconnect. A refresh only applies its result, clears/reschedules its timer, if the
// generation it captured when scheduled is still current - this stops a refresh already in flight when disconnect (and possibly
// reconnect) happens from clobbering a newer refresh's token/promise or rearming a live timer after it should have stopped.
const doRefresh = async (generation: number) => {
let newToken: AccessToken | undefined;
const url = `[${tokenProps.baseUri}/${tokenProps.containerId}]`;
try {
Expand All @@ -117,18 +122,25 @@ export namespace CloudSqlite {
} catch (err: any) {
logError(`Error refreshing token for container ${url}: ${err.message}`);
}
container.accessToken = newToken ?? "";
if (container.refreshGeneration === generation)
container.accessToken = newToken ?? "";
};
const tokenRefreshFn = () => {
const tokenRefreshFn = (generation: number) => {
container.timer = setTimeout(async () => {
container.refreshPromise = doRefresh(); // this promise is stored on the container so it can be awaited in tests
container.refreshPromise = doRefresh(generation); // this promise is stored on the container so it can be awaited in tests
await container.refreshPromise;
container.refreshPromise = undefined;
tokenRefreshFn(); // schedule next refresh
}, refreshSeconds * 1000).unref(); // unref so it doesn't keep the process alive
if (container.refreshGeneration === generation) {
container.refreshPromise = undefined;
tokenRefreshFn(generation); // schedule next refresh
}
}, refreshSeconds * 1000);
Comment thread
aruniverse marked this conversation as resolved.
};
addHiddenProperty(container, "onConnected", tokenRefreshFn); // schedule the first refresh when the container is connected
addHiddenProperty(container, "onConnected", () => { // schedule the first refresh when the container is connected
const generation = ++container.refreshGeneration;
tokenRefreshFn(generation);
});
addHiddenProperty(container, "onDisconnect", () => { // clear the refresh timer when the container is disconnected
++container.refreshGeneration;
if (container.timer !== undefined) {
clearTimeout(container.timer);
container.timer = undefined;
Expand Down
15 changes: 14 additions & 1 deletion core/backend/src/IModelDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ export abstract class IModelDb extends IModel {

IModelDb._openDbs.delete(this._fileKey);
this._workspace?.close();
this.views[_close]();
this.locks[_close]();
this._locks = undefined;
this._codeService?.close();
Expand Down Expand Up @@ -3362,15 +3363,27 @@ export namespace IModelDb {
private _viewStore?: ViewStore.CloudAccess;
public get hasViewStore(): boolean { return undefined !== this._viewStore; }

/** @beta */
/** The [[ViewStore.CloudAccess]] for this iModel.
* @note The iModel owns its ViewStore (whether assigned via this setter or created by [[accessViewStore]]): it is closed when the iModel is closed.
Comment thread
aruniverse marked this conversation as resolved.
* @beta
*/
public get viewStore(): ViewStore.CloudAccess {
if (undefined === this._viewStore)
throw new IModelError(IModelStatus.BadRequest, "No ViewStore available");
return this._viewStore;
}
public set viewStore(viewStore: ViewStore.CloudAccess) {
if (this._viewStore !== undefined && this._viewStore !== viewStore)
this._viewStore.close();
this._viewStore = viewStore;
}
/** Close the ViewStore for this iModel, if one is open. Called when the iModel is closed.
* @internal
*/
public [_close]() {
this._viewStore?.close();
this._viewStore = undefined;
}
/** @beta */
public async accessViewStore(args: { props?: CloudSqlite.ContainerProps, accessLevel?: BlobContainer.RequestAccessLevel }): Promise<ViewStore.CloudAccess> {
let props = args.props;
Expand Down
3 changes: 3 additions & 0 deletions core/backend/src/IModelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { CloudSqlite } from "./CloudSqlite";
import { FunctionalSchema } from "./domains/FunctionalSchema";
import { GenericSchema } from "./domains/GenericSchema";
import { EditTxn } from "./EditTxn";
import { V2CheckpointManager } from "./CheckpointManager";
Comment thread
anmolshres98 marked this conversation as resolved.
import { GeoCoordConfig } from "./GeoCoordConfig";
import { IModelJsFs } from "./IModelJsFs";
import { DevToolsRpcImpl } from "./rpc-impl/DevToolsRpcImpl";
Expand Down Expand Up @@ -716,6 +717,8 @@ export class IModelHost {
this._appWorkspace = undefined;
this._settingsSchemas = undefined;

// safe to disconnect checkpoint containers here: open iModels were already closed by IModelDb's onBeforeShutdown listener above
V2CheckpointManager.cleanup();
CloudSqlite.CloudCaches.destroy();
process.removeListener("beforeExit", IModelHost.shutdown);
}
Expand Down
95 changes: 95 additions & 0 deletions core/backend/src/test/standalone/CloudSqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@

import { expect } from "chai";
import * as sinon from "sinon";
import { NativeLibrary } from "@bentley/imodeljs-native";
import { CloudSqlite } from "../../CloudSqlite";
import { BlobContainer } from "../../BlobContainerService";
import { setOnlineStatus } from "../../internal/OnlineStatus";

class FakeNativeCloudContainer {
public accessToken = "";
public constructor(_args: CloudSqlite.ContainerAccessProps) { }
}

type TestCloudContainer = CloudSqlite.CloudContainer & {
timer?: NodeJS.Timeout;
refreshPromise?: Promise<void>;
};

describe("CloudSqlite.requestToken", () => {
// Supply userToken directly so IModelHost.getAccessToken is never called
const args: CloudSqlite.RequestTokenArgs = {
Expand Down Expand Up @@ -51,3 +62,87 @@ describe("CloudSqlite.requestToken", () => {
expect(token).to.equal("my-sas-token");
});
});

describe("CloudSqlite.createCloudContainer token-refresh scheduling", () => {
afterEach(() => sinon.restore());

it("does not reschedule the refresh timer if the container is disconnected while a refresh is in flight", async () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const fakeNativeLib = { CloudContainer: FakeNativeCloudContainer };
sinon.stub(NativeLibrary, "nativeLib").get(() => fakeNativeLib);

let resolveTokenFn: (token: string) => void = () => { };
const deferredToken = new Promise<string>((resolve) => { resolveTokenFn = resolve; });

const container = CloudSqlite.createCloudContainer({
containerId: "test-container",
baseUri: "https://example.invalid",
storageType: "azure",
accessToken: "",
tokenRefreshSeconds: 0.01,
tokenFn: async () => deferredToken,
}) as TestCloudContainer;

container.onConnected?.(container);

await new Promise((resolve) => setTimeout(resolve, 25));
expect(container.refreshPromise, "doRefresh should be in flight").to.not.be.undefined;

container.onDisconnect?.(container, false);
expect(container.timer, "timer should be cleared on disconnect").to.be.undefined;

resolveTokenFn("late-token");
await deferredToken;
// flush the microtask queue so the rest of the refresh callback (including the reschedule check) runs
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(container.timer, "a disconnected container must not have its refresh timer rescheduled").to.be.undefined;
});

it("does not let a stale refresh overwrite a newer token or clobber a newer refresh's promise after reconnect", async () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const fakeNativeLib = { CloudContainer: FakeNativeCloudContainer };
sinon.stub(NativeLibrary, "nativeLib").get(() => fakeNativeLib);

const resolveFns: Array<(token: string) => void> = [];
const tokenFn = async () => new Promise<string>((resolve) => resolveFns.push(resolve));

const container = CloudSqlite.createCloudContainer({
containerId: "test-container",
baseUri: "https://example.invalid",
storageType: "azure",
accessToken: "",
tokenRefreshSeconds: 0.01,
tokenFn,
}) as TestCloudContainer;

try {
container.onConnected?.(container);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(resolveFns, "first (stale) refresh should be in flight").to.have.lengthOf(1);

// disconnect and reconnect while the first refresh is still in flight; this starts a second, newer refresh
container.onDisconnect?.(container, false);
container.onConnected?.(container);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(resolveFns, "second refresh should now also be in flight").to.have.lengthOf(2);

// let the newer refresh reschedule and start its next refresh, so there's a live `refreshPromise` for the stale one to clobber
resolveFns[1]("newer-token");
await new Promise((resolve) => setTimeout(resolve, 25));
expect(resolveFns, "the newer generation's next refresh should now be in flight").to.have.lengthOf(3);
const currentRefreshPromise = container.refreshPromise;
expect(currentRefreshPromise, "a refresh should currently be in flight").to.not.be.undefined;

resolveFns[0]("stale-token");
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(container.accessToken, "the stale refresh must not overwrite the newer token").to.equal("newer-token");
expect(container.refreshPromise, "the stale refresh must not clobber the current generation's in-flight refreshPromise").to.equal(currentRefreshPromise);
} finally {
container.onDisconnect?.(container, false);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,12 @@ describe("Cloud workspace containers", () => {
AzuriteTest.userToken = AzuriteTest.service.userToken.readWrite;

const workspace = await IModelHost.getITwinWorkspace(testITwinId);
expect(workspace.settings.getNumber("app1/topLevelValue")).equal(99);
expect(workspace.settings.getNumber("app1/nestedValue")).equal(42);
try {
expect(workspace.settings.getNumber("app1/topLevelValue")).equal(99);
expect(workspace.settings.getNumber("app1/nestedValue")).equal(42);
} finally {
workspace.close();
}
} finally {
AzuriteTest.userToken = AzuriteTest.service.userToken.readWrite;
}
Expand Down
6 changes: 3 additions & 3 deletions tools/build/src/mocha-reporter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class BentleyMochaReporter extends Spec {
if (i + 1 < process.argv.length && process.argv[i + 1] === "chrome") {
this._chrome = true;
process.on("chrome-test-runner-done", () => {
this.confirmExit();
this.confirmExit(30);
});
}
break;
Expand All @@ -93,7 +93,7 @@ class BentleyMochaReporter extends Spec {
}
}

private confirmExit(seconds: number = 30) {
private confirmExit(seconds: number = 10) {
Comment thread
aruniverse marked this conversation as resolved.
// NB: By calling unref() on this timer, we stop it from keeping the process alive, so it will only fire if _something else_ is still keeping
// the process alive after n seconds. This also has the benefit of preventing the timer from showing up in wtfnode's dump of open handles.
setTimeout(() => {
Expand Down Expand Up @@ -141,7 +141,7 @@ class BentleyMochaReporter extends Spec {
// Detect hangs caused by tests that leave timers/other handles open - not possible in electron frontends.
if (!this._electron && !this._chrome) {
// Not running in Chrome or Electron, so check for open handles.
this.confirmExit(30);
this.confirmExit();
}

if (!this.stats.pending)
Expand Down
Loading