Skip to content

Commit

Permalink
feat(db): scaling options on creation (#861)
Browse files Browse the repository at this point in the history
  • Loading branch information
neurosnap authored Jul 18, 2024
1 parent 836815b commit 00c1935
Show file tree
Hide file tree
Showing 15 changed files with 639 additions and 302 deletions.
75 changes: 75 additions & 0 deletions src/app/test/create-database.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,81 @@ describe("Create Database flow", () => {
).toBeInTheDocument();
});

it("should successfully provision a database with scaling options applied", async () => {
let counter = 0;
server.use(
...stacksWithResources({
accounts: [testAccount],
databases: [],
}),
...verifiedUserHandlers(),
rest.post(
`${testEnv.apiUrl}/databases/:id/operations`,
async (req, res, ctx) => {
const data = await req.json();
expect(data.container_size).toEqual(4096);
expect(data.disk_size).toEqual(20);
expect(data.instance_profile).toEqual("r5");
return res(ctx.json(testDatabaseOp));
},
),
rest.get(
`${testEnv.apiUrl}/accounts/:envId/operations`,
(_, res, ctx) => {
counter += 1;
const operations = counter === 1 ? [] : [testDatabaseOp];
return res(
ctx.json({
_embedded: { operations },
}),
);
},
),
);

const { App, store } = setupAppIntegrationTest({
initEntries: [`/create/db?environment_id=${testAccount.id}`],
});

await waitForBootup(store);

render(<App />);

await screen.findByText(testAccount.handle);
await screen.findByRole("button", { name: /Save/ });

await screen.findByText(/postgres v14/);
const dbSelector = await screen.findByRole("combobox", {
name: /new-db/,
});
await act(() => userEvent.selectOptions(dbSelector, "postgres v14"));

const profileSelector = await screen.findByRole("combobox", {
name: /container-profile/,
});
await act(() =>
userEvent.selectOptions(profileSelector, "Memory Optimized (R)"),
);

const diskSize = await screen.findByLabelText(/Disk Size/);
fireEvent.change(diskSize, { target: { value: 20 } });

const iops = await screen.findByLabelText(/IOPS/);
fireEvent.change(iops, { target: { value: 4000 } });

const saveBtn = await screen.findByRole("button", {
name: /Save/,
});

// go to next page
fireEvent.click(saveBtn);

await screen.findByText(/Operations show real-time/);
expect(
screen.queryByText(/test-account-1-.+-postgres/),
).toBeInTheDocument();
});

it("should not try to create a db without an imgId (e.g. user didnt pick a db yet)", async () => {
server.use(
...stacksWithResources({
Expand Down
4 changes: 2 additions & 2 deletions src/deploy/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const containerSizesByProfile = (profile: InstanceClass): number[] => {
export const hoursPerMonth = 731;
export const computedCostsForContainer = (
containerCount: number,
containerProfile: ContainerProfileData,
containerProfile: Pick<ContainerProfileData, "costPerContainerHourInCents">,
containerSizeGB: number,
) => {
const estimatedCostInCents = () => {
Expand All @@ -48,7 +48,7 @@ export const computedCostsForContainer = (

export const hourlyAndMonthlyCostsForContainers = (
containerCount: number,
containerProfile: ContainerProfileData,
containerProfile: Pick<ContainerProfileData, "costPerContainerHourInCents">,
containerSize: number,
diskSize?: number,
) => {
Expand Down
22 changes: 17 additions & 5 deletions src/deploy/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { type FetchJson, type Payload, call, parallel, select } from "@app/fx";
import { createSelector } from "@app/fx";
import { defaultEntity, extractIdFromLink } from "@app/hal";
import { selectOrganizationSelectedId } from "@app/organizations";
import { type WebState, schema } from "@app/schema";
import { DEFAULT_INSTANCE_CLASS, type WebState, schema } from "@app/schema";
import { capitalize } from "@app/string-utils";
import type {
DeployApiCtx,
Expand Down Expand Up @@ -229,12 +229,16 @@ export const fetchDatabasesByEnvId = api.get<
HalEmbedded<{ databases: DeployDatabaseResponse[] }>
>("/accounts/:envId/databases");

interface CreateDatabaseProps {
export interface CreateDatabaseProps {
handle: string;
type: string;
envId: string;
databaseImageId: string;
enableBackups: boolean;
diskSize?: number;
iops?: number;
containerProfile?: InstanceClass;
containerSize?: number;
}
/**
* This will only create a database record, it will not trigger it to actually be provisioned.
Expand Down Expand Up @@ -399,8 +403,11 @@ export const provisionDatabase = thunks.create<
createDatabaseOperation.run(
createDatabaseOperation({
dbId,
containerSize: 1024,
diskSize: 10,
iops: ctx.payload.iops || 3000,
containerProfile:
ctx.payload.containerProfile || DEFAULT_INSTANCE_CLASS,
containerSize: ctx.payload.containerSize || 1024,
diskSize: ctx.payload.diskSize || 10,
type: "provision",
envId: ctx.payload.envId,
}),
Expand Down Expand Up @@ -434,6 +441,8 @@ interface CreateDatabaseOpProps {
dbId: string;
containerSize: number;
diskSize: number;
containerProfile?: InstanceClass;
iops?: number;
type: "provision";
envId: string;
}
Expand Down Expand Up @@ -464,10 +473,13 @@ export const createDatabaseOperation = api.post<
}

case "provision": {
const { containerSize, diskSize, type } = ctx.payload;
const { containerSize, diskSize, type, iops, containerProfile } =
ctx.payload;
return {
container_size: containerSize,
disk_size: diskSize,
provisioned_iops: iops,
instance_profile: containerProfile,
type,
};
}
Expand Down
4 changes: 1 addition & 3 deletions src/deploy/service/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { api, cacheMinTimer, cacheShortTimer } from "@app/api";
import { createSelector } from "@app/fx";
import { defaultEntity, defaultHalHref, extractIdFromLink } from "@app/hal";
import { type WebState, schema } from "@app/schema";
import { DEFAULT_INSTANCE_CLASS, type WebState, schema } from "@app/schema";
import {
type ContainerProfileData,
type DeployOperation,
Expand All @@ -14,8 +14,6 @@ import { computedCostsForContainer } from "../app/utils";
import { CONTAINER_PROFILES, GB } from "../container/utils";
import { selectEnvironmentsByOrgAsList } from "../environment";

export const DEFAULT_INSTANCE_CLASS: InstanceClass = "m5";

export const defaultServiceResponse = (
s: Partial<DeployServiceResponse> = {},
): DeployServiceResponse => {
Expand Down
11 changes: 7 additions & 4 deletions src/schema/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type DeployVpnTunnel,
type Deployment,
type GithubIntegration,
type InstanceClass,
type Invitation,
type Membership,
ModalType,
Expand All @@ -43,6 +44,8 @@ import {
type User,
} from "@app/types";

export const DEFAULT_INSTANCE_CLASS: InstanceClass = "m5";

export const defaultConfig = (e: Partial<Config> = {}): Config => {
return {
isProduction: import.meta.env.PROD,
Expand Down Expand Up @@ -252,7 +255,7 @@ export const defaultDeployDisk = (d: Partial<DeployDisk> = {}): DeployDisk => {
attached: true,
availabilityZone: "",
baselineIops: 0,
provisionedIops: 0,
provisionedIops: 3000,
createdAt: now,
updatedAt: now,
currentKmsArn: "",
Expand All @@ -263,7 +266,7 @@ export const defaultDeployDisk = (d: Partial<DeployDisk> = {}): DeployDisk => {
filesystem: "",
handle: "",
host: "",
size: 0,
size: 10,
keyBytes: 32,
...d,
};
Expand Down Expand Up @@ -343,9 +346,9 @@ export const defaultDeployService = (
processType: "",
command: "",
containerCount: 0,
containerMemoryLimitMb: 0,
containerMemoryLimitMb: 512,
currentReleaseId: "",
instanceClass: "m5",
instanceClass: DEFAULT_INSTANCE_CLASS,
createdAt: now,
updatedAt: now,
...s,
Expand Down
1 change: 1 addition & 0 deletions src/ui/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from "./use-paginated-activity-reports";
export * from "./use-users-for-role";
export * from "./use-poll-app-ops";
export * from "./use-paginate-operations";
export * from "./use-database-scaler";
115 changes: 115 additions & 0 deletions src/ui/hooks/use-database-scaler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
getContainerProfileFromType,
hourlyAndMonthlyCostsForContainers,
} from "@app/deploy";
import type { DeployDisk, DeployService, InstanceClass } from "@app/types";
import { useEffect, useReducer } from "react";

export interface DbScaleOptions {
diskSize: number;
containerSize: number;
containerProfile: InstanceClass;
iops: number;
}

export type DbScaleAction =
| { type: "diskSize"; payload: number }
| { type: "containerSize"; payload: number }
| { type: "containerProfile"; payload: InstanceClass }
| { type: "iops"; payload: number }
| { type: "set"; payload: DbScaleOptions };

function dbScaleReducer(
state: DbScaleOptions,
action: DbScaleAction,
): DbScaleOptions {
switch (action.type) {
case "set":
return action.payload;
case "diskSize":
return { ...state, diskSize: action.payload };
case "iops":
return { ...state, iops: action.payload };
case "containerSize":
return { ...state, containerSize: action.payload };
case "containerProfile": {
const profile = getContainerProfileFromType(action.payload);
const containerSize = Math.max(
state.containerSize,
profile.minimumContainerSize,
);
return { ...state, containerProfile: action.payload, containerSize };
}
default:
return state;
}
}

export function defaultDatabaseScaler(
service: DeployService,
disk: DeployDisk,
): DbScaleOptions {
return {
diskSize: disk.size,
iops: disk.provisionedIops,
containerSize: service.containerMemoryLimitMb,
containerProfile: service.instanceClass,
};
}

export function useDatabaseScaler({
service,
disk,
}: { service: DeployService; disk: DeployDisk }) {
const opts = defaultDatabaseScaler(service, disk);
const [scaler, dispatchScaler] = useReducer(dbScaleReducer, opts);

// if source of truth has changed, update `scaler` obj
useEffect(() => {
dispatchScaler({ type: "set", payload: opts });
}, [
disk.size,
disk.provisionedIops,
service.containerMemoryLimitMb,
service.instanceClass,
]);

const changesExist =
service.containerMemoryLimitMb !== scaler.containerSize ||
service.instanceClass !== scaler.containerProfile ||
disk.size !== scaler.diskSize ||
disk.provisionedIops !== scaler.iops;

const currentContainerProfile = getContainerProfileFromType(
service.instanceClass,
);
const requestedContainerProfile = getContainerProfileFromType(
scaler.containerProfile,
);
const { pricePerHour: currentPricePerHour, pricePerMonth: currentPrice } =
hourlyAndMonthlyCostsForContainers(
service.containerCount,
currentContainerProfile,
service.containerMemoryLimitMb,
disk.size,
);
const { pricePerHour: estimatedPricePerHour, pricePerMonth: estimatedPrice } =
hourlyAndMonthlyCostsForContainers(
1,
requestedContainerProfile,
scaler.containerSize,
scaler.diskSize,
);

return {
scaler,
dispatchScaler,
changesExist,
currentPricePerHour,
currentPrice,
estimatedPricePerHour,
estimatedPrice,
requestedContainerProfile,
currentContainerProfile,
};
}
3 changes: 1 addition & 2 deletions src/ui/pages/app-detail-service-scale.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { prettyDateTime } from "@app/date";
import {
DEFAULT_INSTANCE_CLASS,
type ServiceSizingPolicyEditProps,
cancelServicesOpsPoll,
containerSizesByProfile,
Expand Down Expand Up @@ -29,7 +28,7 @@ import {
useSelector,
} from "@app/react";
import { appActivityUrl } from "@app/routes";
import { schema } from "@app/schema";
import { DEFAULT_INSTANCE_CLASS, schema } from "@app/schema";
import type { DeployOperation, InstanceClass } from "@app/types";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router";
Expand Down
Loading

0 comments on commit 00c1935

Please sign in to comment.