diff --git a/packages/compass-global-writes/src/components/example-commands-markup.spec.tsx b/packages/compass-global-writes/src/components/example-commands-markup.spec.tsx new file mode 100644 index 00000000000..77cc92ed53e --- /dev/null +++ b/packages/compass-global-writes/src/components/example-commands-markup.spec.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { renderWithStore } from '../../tests/create-store'; +import { expect } from 'chai'; +import { screen } from '@mongodb-js/testing-library-compass'; +import ExampleCommandsMarkup, { + type ExampleCommandsMarkupProps, +} from './example-commands-markup'; +import { type ShardKey } from '../store/reducer'; + +describe('ExampleCommandsMarkup', function () { + const db = 'db1'; + const coll = 'coll1'; + const namespace = `${db}.${coll}`; + const shardKey: ShardKey = { + fields: [ + { type: 'RANGE', name: 'location' }, + { type: 'HASHED', name: 'secondary' }, + ], + isUnique: false, + }; + + function renderWithProps(props?: Partial) { + return renderWithStore( + + ); + } + + it('Contains sample codes', async function () { + await renderWithProps(); + + const findingDocumentsSample = await screen.findByTestId( + 'sample-finding-documents' + ); + expect(findingDocumentsSample).to.be.visible; + expect(findingDocumentsSample.textContent).to.contain( + `use db1db["coll1"].find({"location": "US-NY", "secondary": ""})` + ); + + const insertingDocumentsSample = await screen.findByTestId( + 'sample-inserting-documents' + ); + expect(insertingDocumentsSample).to.be.visible; + expect(insertingDocumentsSample.textContent).to.contain( + `use db1db["coll1"].insertOne({"location": "US-NY", "secondary": "",...})` + ); + }); +}); diff --git a/packages/compass-global-writes/src/components/example-commands-markup.tsx b/packages/compass-global-writes/src/components/example-commands-markup.tsx new file mode 100644 index 00000000000..5171c3ecc5d --- /dev/null +++ b/packages/compass-global-writes/src/components/example-commands-markup.tsx @@ -0,0 +1,99 @@ +import { + Body, + Code, + css, + Label, + Link, + spacing, + Subtitle, +} from '@mongodb-js/compass-components'; +import React, { useMemo } from 'react'; +import type { ShardKey } from '../store/reducer'; +import toNS from 'mongodb-ns'; + +const codeBlockContainerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[100], +}); + +export interface ExampleCommandsMarkupProps { + shardKey: ShardKey; + namespace: string; + showMetaData?: boolean; + type?: 'requested' | 'existing'; +} + +const paragraphStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[100], +}); + +export function ExampleCommandsMarkup({ + namespace, + shardKey, +}: ExampleCommandsMarkupProps) { + const customShardKeyField = useMemo(() => { + return shardKey.fields[1].name; + }, [shardKey]); + + const sampleCodes = useMemo(() => { + const { collection, database } = toNS(namespace); + return { + findingDocuments: `use ${database}\ndb[${JSON.stringify( + collection + )}].find({"location": "US-NY", "${customShardKeyField}": ""})`, + insertingDocuments: `use ${database}\ndb[${JSON.stringify( + collection + )}].insertOne({"location": "US-NY", "${customShardKeyField}": "",...})`, + }; + }, [namespace, customShardKeyField]); + + return ( + <> + Example commands +
+ + Start querying your database with some of the most{' '} + + common commands + {' '} + for Global Writes. + + + Replace the text to perform operations on different documents. US-NY + is an ISO 3166 location code referring to New York, United States. You + can look up other ISO 3166 location codes below. + +
+ +
+ + + {sampleCodes.findingDocuments} + +
+ +
+ + + {sampleCodes.insertingDocuments} + +
+ + ); +} + +export default ExampleCommandsMarkup; diff --git a/packages/compass-global-writes/src/components/index.tsx b/packages/compass-global-writes/src/components/index.tsx index f27f9c2a989..101d56db6b5 100644 --- a/packages/compass-global-writes/src/components/index.tsx +++ b/packages/compass-global-writes/src/components/index.tsx @@ -15,6 +15,8 @@ import ShardKeyCorrect from './states/shard-key-correct'; import ShardKeyInvalid from './states/shard-key-invalid'; import ShardKeyMismatch from './states/shard-key-mismatch'; import ShardingError from './states/sharding-error'; +import IncompleteShardingSetup from './states/incomplete-sharding-setup'; +import LoadingError from './states/loading-error'; const containerStyles = css({ paddingLeft: spacing[400], @@ -93,6 +95,17 @@ function ShardingStateView({ return ; } + if ( + shardingStatus === ShardingStatuses.INCOMPLETE_SHARDING_SETUP || + shardingStatus === ShardingStatuses.SUBMITTING_FOR_SHARDING_INCOMPLETE + ) { + return ; + } + + if (shardingStatus === ShardingStatuses.LOADING_ERROR) { + return ; + } + return null; } diff --git a/packages/compass-global-writes/src/components/shard-zones-description.tsx b/packages/compass-global-writes/src/components/shard-zones-description.tsx new file mode 100644 index 00000000000..0e3d7e395ed --- /dev/null +++ b/packages/compass-global-writes/src/components/shard-zones-description.tsx @@ -0,0 +1,53 @@ +import { + Body, + css, + Link, + spacing, + Subtitle, +} from '@mongodb-js/compass-components'; +import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; +import React from 'react'; + +const paragraphStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[100], +}); + +export function ShardZonesDescription() { + const { atlasMetadata } = useConnectionInfo(); + return ( + <> + Location Codes +
+ + Each document’s first field should include an ISO 3166-1 Alpha-2 code + for the location it belongs to. + + + We also support ISO 3166-2 subdivision codes for countries containing + a cloud provider data center (both ISO 3166-1 and ISO 3166-2 codes may + be used for these countries). All valid country codes and the zones to + which they map are listed in the table below. Additionally, you can + view a list of all location codes{' '} + here. + + + {atlasMetadata?.projectId && atlasMetadata?.clusterName && ( + <> + Locations’ zone mapping can be changed by navigating to this + clusters{' '} + + Edit Configuration + {' '} + page and clicking the Configure Location Mappings’ link above the + map. + + )} + +
+ + ); +} diff --git a/packages/compass-global-writes/src/components/shard-zones-descripton.spec.tsx b/packages/compass-global-writes/src/components/shard-zones-descripton.spec.tsx new file mode 100644 index 00000000000..fe58b3819d0 --- /dev/null +++ b/packages/compass-global-writes/src/components/shard-zones-descripton.spec.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { expect } from 'chai'; +import { screen } from '@mongodb-js/testing-library-compass'; +import type { ConnectionInfo } from '@mongodb-js/compass-connections/provider'; +import { renderWithStore } from '../../tests/create-store'; +import { ShardZonesDescription } from './shard-zones-description'; + +describe('ShardZonesDescription', () => { + it('Provides link to Edit Configuration', async function () { + const connectionInfo = { + id: 'testConnection', + connectionOptions: { + connectionString: 'mongodb://test', + }, + atlasMetadata: { + projectId: 'project1', + clusterName: 'myCluster', + } as ConnectionInfo['atlasMetadata'], + }; + await renderWithStore(, { + connectionInfo, + }); + + const link = await screen.findByRole('link', { + name: /Edit Configuration/, + }); + const expectedHref = `/v2/${connectionInfo.atlasMetadata?.projectId}#/clusters/edit/${connectionInfo.atlasMetadata?.clusterName}`; + + expect(link).to.be.visible; + expect(link).to.have.attribute('href', expectedHref); + }); +}); diff --git a/packages/compass-global-writes/src/components/shard-zones-table.tsx b/packages/compass-global-writes/src/components/shard-zones-table.tsx index 6d3952235d0..f30352f4c44 100644 --- a/packages/compass-global-writes/src/components/shard-zones-table.tsx +++ b/packages/compass-global-writes/src/components/shard-zones-table.tsx @@ -19,6 +19,7 @@ import { type LgTableRowType, } from '@mongodb-js/compass-components'; import type { ShardZoneData } from '../store/reducer'; +import { ShardZonesDescription } from './shard-zones-description'; const containerStyles = css({ height: '400px', @@ -131,6 +132,7 @@ export function ShardZonesTable({ return ( <> + {}, + }; + + const connectionInfo = { + id: 'testConnection', + connectionOptions: { + connectionString: 'mongodb://test', + }, + atlasMetadata: { + projectId: 'project1', + clusterName: 'myCluster', + } as ConnectionInfo['atlasMetadata'], + }; + + function renderWithProps( + props?: Partial, + options?: Parameters[1] + ) { + return renderWithStore( + , + { + connectionInfo, + ...options, + } + ); + } + + it('Shows description', async function () { + await renderWithProps(); + + expect(screen.findByText(/your configuration is incomplete/)).to.be.exist; + expect(screen.findByText(/Please enable Global Writes/)).to.be.exist; + }); + + it('Provides button to resume managed namespace', async function () { + const onResume = Sinon.spy(); + await renderWithProps({ onResume }); + + const btn = await screen.findByRole('button', { + name: /Enable Global Writes/, + }); + expect(btn).to.be.visible; + + userEvent.click(btn); + + expect(onResume).to.have.been.calledOnce; + }); + + it('Manage btn is disabled when the action is in progress', async function () { + const onResume = Sinon.spy(); + await renderWithProps({ onResume, isSubmittingForSharding: true }); + + const btn = await screen.findByTestId( + 'manage-collection-button' + ); + expect(btn).to.be.visible; + expect(btn.getAttribute('aria-disabled')).to.equal('true'); + + userEvent.click(btn); + + expect(onResume).not.to.have.been.called; + }); + + it('Describes the shardKey', async function () { + await renderWithProps(); + + const title = await screen.findByTestId( + 'existing-shardkey-description-title' + ); + expect(title).to.be.visible; + expect(title.textContent).to.equal( + `${baseProps.namespace} is configured with the following shard key:` + ); + const list = await screen.findByTestId( + 'existing-shardkey-description-content' + ); + expect(list).to.be.visible; + expect(list.textContent).to.contain(`"location", "secondary"`); + }); + + it('Includes code examples', async function () { + await renderWithProps(); + + const example = await screen.findByText(/Example commands/); + expect(example).to.be.visible; + }); +}); diff --git a/packages/compass-global-writes/src/components/states/incomplete-sharding-setup.tsx b/packages/compass-global-writes/src/components/states/incomplete-sharding-setup.tsx new file mode 100644 index 00000000000..8f5b4e0ba4c --- /dev/null +++ b/packages/compass-global-writes/src/components/states/incomplete-sharding-setup.tsx @@ -0,0 +1,102 @@ +import { + Banner, + BannerVariant, + Button, + spacing, + css, + ButtonVariant, + Link, + SpinLoader, +} from '@mongodb-js/compass-components'; +import React from 'react'; +import ShardKeyMarkup from '../shard-key-markup'; +import { + resumeManagedNamespace, + ShardingStatuses, + type ShardZoneData, + type RootState, + type ShardKey, +} from '../../store/reducer'; +import { connect } from 'react-redux'; +import ExampleCommandsMarkup from '../example-commands-markup'; +import { ShardZonesTable } from '../shard-zones-table'; + +const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[400], + marginBottom: spacing[400], +}); + +const manageBtnStyles = css({ + marginTop: spacing[100], +}); + +export interface IncompleteShardingSetupProps { + shardKey: ShardKey; + shardZones: ShardZoneData[]; + namespace: string; + isSubmittingForSharding: boolean; + onResume: () => void; +} + +export function IncompleteShardingSetup({ + shardKey, + shardZones, + namespace, + onResume, + isSubmittingForSharding, +}: IncompleteShardingSetupProps) { + return ( +
+ + + It looks like you've chosen a Global Writes shard key for this + collection, but your configuration is incomplete. + {' '} + Please enable Global Writes for this collection to ensure that documents + are associated with the appropriate zone.  + + Read more about Global Writes + +
+ +
+
+ + + +
+ ); +} + +export default connect( + (state: RootState) => { + if (!state.shardKey) { + throw new Error('Shard key not found in IncompleteShardingSetup'); + } + return { + namespace: state.namespace, + shardKey: state.shardKey, + shardZones: state.shardZones, + isSubmittingForSharding: + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_INCOMPLETE, + }; + }, + { + onResume: resumeManagedNamespace, + } +)(IncompleteShardingSetup); diff --git a/packages/compass-global-writes/src/components/states/loading-error.tsx b/packages/compass-global-writes/src/components/states/loading-error.tsx new file mode 100644 index 00000000000..7dafb76ac64 --- /dev/null +++ b/packages/compass-global-writes/src/components/states/loading-error.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ErrorSummary } from '@mongodb-js/compass-components'; +import { connect } from 'react-redux'; +import { type RootState, ShardingStatuses } from '../../store/reducer'; +import { containerStyles } from '../common-styles'; + +interface LoadingErrorProps { + error: string; +} + +export function LoadingError({ error }: LoadingErrorProps) { + return ( +
+ +
+ ); +} + +export default connect((state: RootState) => { + if (state.status !== ShardingStatuses.LOADING_ERROR) { + throw new Error('Error not found in LoadingError'); + } + return { + error: state.error, + }; +})(LoadingError); diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx index 504db736e4e..72728148cd1 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx @@ -8,9 +8,8 @@ import { import { type ShardZoneData } from '../../store/reducer'; import Sinon from 'sinon'; import { renderWithStore } from '../../../tests/create-store'; -import { type ConnectionInfo } from '@mongodb-js/compass-connections/provider'; -describe('Compass GlobalWrites Plugin', function () { +describe('ShardKeyCorrect', function () { const shardZones: ShardZoneData[] = [ { zoneId: '45893084', @@ -76,30 +75,6 @@ describe('Compass GlobalWrites Plugin', function () { expect(onUnmanageNamespace).not.to.have.been.called; }); - it('Provides link to Edit Configuration', async function () { - const connectionInfo = { - id: 'testConnection', - connectionOptions: { - connectionString: 'mongodb://test', - }, - atlasMetadata: { - projectId: 'project1', - clusterName: 'myCluster', - } as ConnectionInfo['atlasMetadata'], - }; - await renderWithProps(undefined, { - connectionInfo, - }); - - const link = await screen.findByRole('link', { - name: /Edit Configuration/, - }); - const expectedHref = `/v2/${connectionInfo.atlasMetadata?.projectId}#/clusters/edit/${connectionInfo.atlasMetadata?.clusterName}`; - - expect(link).to.be.visible; - expect(link).to.have.attribute('href', expectedHref); - }); - it('Describes the shardKey', async function () { await renderWithProps(); @@ -117,23 +92,10 @@ describe('Compass GlobalWrites Plugin', function () { expect(list.textContent).to.contain(`"location", "secondary"`); }); - it('Contains sample codes', async function () { + it('Includes code examples', async function () { await renderWithProps(); - const findingDocumentsSample = await screen.findByTestId( - 'sample-finding-documents' - ); - expect(findingDocumentsSample).to.be.visible; - expect(findingDocumentsSample.textContent).to.contain( - `use db1db["coll1"].find({"location": "US-NY", "secondary": ""})` - ); - - const insertingDocumentsSample = await screen.findByTestId( - 'sample-inserting-documents' - ); - expect(insertingDocumentsSample).to.be.visible; - expect(insertingDocumentsSample.textContent).to.contain( - `use db1db["coll1"].insertOne({"location": "US-NY", "secondary": "",...})` - ); + const example = await screen.findByText(/Example commands/); + expect(example).to.be.visible; }); }); diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx index 12e7f64f313..356a24ca4e9 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-correct.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx @@ -3,12 +3,7 @@ import { Banner, BannerVariant, Body, - css, - Link, - spacing, - Code, Subtitle, - Label, Button, ButtonVariant, SpinLoader, @@ -21,24 +16,13 @@ import { type ShardKey, type ShardZoneData, } from '../../store/reducer'; -import toNS from 'mongodb-ns'; import { ShardZonesTable } from '../shard-zones-table'; -import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; import ShardKeyMarkup from '../shard-key-markup'; -import { - containerStyles, - paragraphStyles, - bannerStyles, -} from '../common-styles'; +import { containerStyles, bannerStyles } from '../common-styles'; +import ExampleCommandsMarkup from '../example-commands-markup'; const nbsp = '\u00a0'; -const codeBlockContainerStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[100], -}); - export type ShardKeyCorrectProps = { namespace: string; shardKey: ShardKey; @@ -58,20 +42,6 @@ export function ShardKeyCorrect({ return shardKey.fields[1].name; }, [shardKey]); - const { atlasMetadata } = useConnectionInfo(); - - const sampleCodes = useMemo(() => { - const { collection, database } = toNS(namespace); - return { - findingDocuments: `use ${database}\ndb[${JSON.stringify( - collection - )}].find({"location": "US-NY", "${customShardKeyField}": ""})`, - insertingDocuments: `use ${database}\ndb[${JSON.stringify( - collection - )}].insertOne({"location": "US-NY", "${customShardKeyField}": "",...})`, - }; - }, [namespace, customShardKeyField]); - return (
@@ -83,77 +53,7 @@ export function ShardKeyCorrect({ {nbsp}We have included a table for reference below. - Example commands -
- - Start querying your database with some of the most{' '} - - common commands - {' '} - for Global Writes. - - - Replace the text to perform operations on different documents. US-NY - is an ISO 3166 location code referring to New York, United States. You - can look up other ISO 3166 location codes below. - -
- -
- - - {sampleCodes.findingDocuments} - -
- -
- - - {sampleCodes.insertingDocuments} - -
- - Location Codes -
- - Each document’s first field should include an ISO 3166-1 Alpha-2 code - for the location it belongs to. - - - We also support ISO 3166-2 subdivision codes for countries containing - a cloud provider data center (both ISO 3166-1 and ISO 3166-2 codes may - be used for these countries). All valid country codes and the zones to - which they map are listed in the table below. Additionally, you can - view a list of all location codes{' '} - here. - - - {atlasMetadata?.projectId && atlasMetadata?.clusterName && ( - <> - Locations’ zone mapping can be changed by navigating to this - clusters{' '} - - Edit Configuration - {' '} - page and clicking the Configure Location Mappings’ link above the - map. - - )} - -
+ diff --git a/packages/compass-global-writes/src/components/states/shard-key-invalid.spec.tsx b/packages/compass-global-writes/src/components/states/shard-key-invalid.spec.tsx index 84fc8829e7e..acc8c775e7a 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-invalid.spec.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-invalid.spec.tsx @@ -7,7 +7,7 @@ import { } from './shard-key-invalid'; import { renderWithStore } from '../../../tests/create-store'; -describe('Compass GlobalWrites Plugin', function () { +describe('ShardKeyInvalid', function () { const baseProps: ShardKeyInvalidProps = { namespace: 'db1.coll1', shardKey: { diff --git a/packages/compass-global-writes/src/components/states/shard-key-mismatch.spec.tsx b/packages/compass-global-writes/src/components/states/shard-key-mismatch.spec.tsx index 25bf89f7193..67dbf52bd75 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-mismatch.spec.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-mismatch.spec.tsx @@ -8,7 +8,7 @@ import { import { renderWithStore } from '../../../tests/create-store'; import Sinon from 'sinon'; -describe('Compass GlobalWrites Plugin', function () { +describe('ShardKeyMismatch', function () { const baseProps: ShardKeyMismatchProps = { namespace: 'db1.coll1', shardKey: { diff --git a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts index 8ce8f1456c7..f36f224637e 100644 --- a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts +++ b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts @@ -135,7 +135,7 @@ export class AtlasGlobalWritesService { ); } - async createShardKey(namespace: string, keyData: CreateShardKeyData) { + async manageNamespace(namespace: string, keyData: CreateShardKeyData) { const clusterDetails = await this.getClusterDetails(); const { database, collection } = toNS(namespace); const requestData: GeoShardingData = { @@ -222,7 +222,7 @@ export class AtlasGlobalWritesService { const data = res.response; if (data.length === 0) { - return null; + return undefined; } const { key, unique } = data[0]; diff --git a/packages/compass-global-writes/src/store/index.spec.ts b/packages/compass-global-writes/src/store/index.spec.ts index a32c7d032ff..b6e905ac54b 100644 --- a/packages/compass-global-writes/src/store/index.spec.ts +++ b/packages/compass-global-writes/src/store/index.spec.ts @@ -6,6 +6,7 @@ import { type CreateShardKeyData, unmanageNamespace, cancelSharding, + resumeManagedNamespace, POLLING_INTERVAL, type ShardKey, } from './reducer'; @@ -89,7 +90,7 @@ function createStore({ } = {}): GlobalWritesStore { const atlasService = { authenticatedFetch: (uri: string) => { - if (uri.includes(`/geoSharding`) && failsOnShardingRequest()) { + if (uri.endsWith(`/geoSharding`) && failsOnShardingRequest()) { return Promise.reject(new Error('Failed to shard')); } @@ -179,9 +180,11 @@ describe('GlobalWritesStore Store', function () { context('scenarios', function () { it('not managed -> sharding -> valid shard key', async function () { let mockShardKey = false; + let mockManagedNamespace = false; // initial state === unsharded const store = createStore({ hasShardKey: Sinon.fake(() => mockShardKey), + isNamespaceManaged: Sinon.fake(() => mockManagedNamespace), }); await waitFor(() => { expect(store.getState().status).to.equal('UNSHARDED'); @@ -194,6 +197,7 @@ describe('GlobalWritesStore Store', function () { }); const promise = store.dispatch(createShardKey(shardKeyData)); expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); + mockManagedNamespace = true; await promise; expect(store.getState().status).to.equal('SHARDING'); @@ -309,7 +313,101 @@ describe('GlobalWritesStore Store', function () { }); }); - it('valid shard key -> not managed', async function () { + it('incomplete setup -> sharding -> shard key correct', async function () { + // initial state -> incomplete shardingSetup + clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + let mockManagedNamespace = false; + const store = createStore({ + isNamespaceManaged: Sinon.fake(() => mockManagedNamespace), + hasShardKey: () => true, + }); + await waitFor(() => { + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); + expect(store.getState().managedNamespace).to.be.undefined; + }); + + // user asks to resume geosharding + const promise = store.dispatch(resumeManagedNamespace()); + mockManagedNamespace = true; + expect(store.getState().status).to.equal( + 'SUBMITTING_FOR_SHARDING_INCOMPLETE' + ); + await promise; + + // sharding + expect(store.getState().status).to.equal('SHARDING'); + + // done + clock.tick(POLLING_INTERVAL); + await waitFor(() => { + expect(store.getState().status).to.equal('SHARD_KEY_CORRECT'); + }); + }); + + it('incomplete setup -> sharding -> incomplete setup (request was cancelled)', async function () { + // initial state -> incomplete shardingSetup + clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + const store = createStore({ + isNamespaceManaged: () => false, + hasShardKey: () => true, + }); + await waitFor(() => { + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); + expect(store.getState().managedNamespace).to.be.undefined; + }); + + // user asks to resume geosharding + const promise = store.dispatch(resumeManagedNamespace()); + expect(store.getState().status).to.equal( + 'SUBMITTING_FOR_SHARDING_INCOMPLETE' + ); + await promise; + + // sharding + expect(store.getState().status).to.equal('SHARDING'); + + // user cancels the request - we go back to incomplete + const promise2 = store.dispatch(cancelSharding()); + await promise2; + clock.tick(POLLING_INTERVAL); + await waitFor(() => { + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); + }); + }); + + it('incomplete setup -> incomplete setup (failed manage attempt)', async function () { + // initial state -> incomplete shardingSetup + clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + const store = createStore({ + isNamespaceManaged: () => false, + hasShardKey: () => true, + failsOnShardingRequest: () => true, + }); + await waitFor(() => { + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); + expect(store.getState().managedNamespace).to.be.undefined; + }); + + // user asks to resume geosharding + const promise = store.dispatch(resumeManagedNamespace()); + expect(store.getState().status).to.equal( + 'SUBMITTING_FOR_SHARDING_INCOMPLETE' + ); + await promise; + + // it failed + await waitFor(() => { + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); + }); + }); + + it('valid shard key -> incomplete', async function () { // initial state === shard key correct const store = createStore({ isNamespaceManaged: () => true, @@ -324,7 +422,7 @@ describe('GlobalWritesStore Store', function () { const promise = store.dispatch(unmanageNamespace()); expect(store.getState().status).to.equal('UNMANAGING_NAMESPACE'); await promise; - expect(store.getState().status).to.equal('UNSHARDED'); + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); }); it('valid shard key -> valid shard key (failed unmanage attempt)', async function () { @@ -420,7 +518,7 @@ describe('GlobalWritesStore Store', function () { }); }); - it('mismatch -> unmanaged', async function () { + it('mismatch -> incomplete sharding setup', async function () { // initial state - mismatch const store = createStore({ isNamespaceManaged: () => true, @@ -443,7 +541,7 @@ describe('GlobalWritesStore Store', function () { 'UNMANAGING_NAMESPACE_MISMATCH' ); await promise; - expect(store.getState().status).to.equal('UNSHARDED'); + expect(store.getState().status).to.equal('INCOMPLETE_SHARDING_SETUP'); }); }); diff --git a/packages/compass-global-writes/src/store/reducer.ts b/packages/compass-global-writes/src/store/reducer.ts index 97b6d18b3c8..db078f91f98 100644 --- a/packages/compass-global-writes/src/store/reducer.ts +++ b/packages/compass-global-writes/src/store/reducer.ts @@ -45,6 +45,8 @@ enum GlobalWritesActionTypes { UnmanagingNamespaceStarted = 'global-writes/UnmanagingNamespaceStarted', UnmanagingNamespaceFinished = 'global-writes/UnmanagingNamespaceFinished', UnmanagingNamespaceErrored = 'global-writes/UnmanagingNamespaceErrored', + + LoadingFailed = 'global-writes/LoadingFailed', } type ManagedNamespaceFetchedAction = { @@ -52,6 +54,11 @@ type ManagedNamespaceFetchedAction = { managedNamespace?: ManagedNamespace; }; +type LoadingFailedAction = { + type: GlobalWritesActionTypes.LoadingFailed; + error: string; +}; + type NamespaceShardingErrorFetchedAction = { type: GlobalWritesActionTypes.NamespaceShardingErrorFetched; error: string; @@ -59,7 +66,7 @@ type NamespaceShardingErrorFetchedAction = { type NamespaceShardKeyFetchedAction = { type: GlobalWritesActionTypes.NamespaceShardKeyFetched; - shardKey: ShardKey; + shardKey?: ShardKey; }; type ShardZonesFetchedAction = { @@ -120,17 +127,30 @@ export enum ShardingStatuses { */ NOT_READY = 'NOT_READY', + /** + * The status could not be determined because loading failed + */ + LOADING_ERROR = 'LOADING_ERROR', + /** * Namespace is not geo-sharded. */ UNSHARDED = 'UNSHARDED', + /** + * Incomplete sharding setup + * sharding key exists but namespace is not managed + * (can happen when already sharded namespace is unmanaged) + */ + INCOMPLETE_SHARDING_SETUP = 'INCOMPLETE_SHARDING_SETUP', + /** * State when user submits namespace to be sharded and * we are waiting for server to accept the request. */ SUBMITTING_FOR_SHARDING = 'SUBMITTING_FOR_SHARDING', SUBMITTING_FOR_SHARDING_ERROR = 'SUBMITTING_FOR_SHARDING_ERROR', + SUBMITTING_FOR_SHARDING_INCOMPLETE = 'SUBMITTING_FOR_SHARDING_INCOMPLETE', /** * Namespace is being sharded. @@ -196,11 +216,19 @@ export type RootState = { managedNamespace?: ManagedNamespace; shardZones: ShardZoneData[]; } & ( + | { + status: ShardingStatuses.LOADING_ERROR; + shardKey?: ShardKey; + shardingError?: never; + pollingTimeout?: never; + error: string; + } | { status: ShardingStatuses.NOT_READY; shardKey?: never; shardingError?: never; pollingTimeout?: never; + error?: never; } | { status: @@ -214,6 +242,7 @@ export type RootState = { shardKey?: ShardKey; shardingError?: never; pollingTimeout?: never; + error?: never; } | { status: ShardingStatuses.SHARDING; @@ -224,6 +253,7 @@ export type RootState = { shardKey?: ShardKey; shardingError?: never; pollingTimeout?: NodeJS.Timeout; + error?: never; } | { status: @@ -233,6 +263,7 @@ export type RootState = { shardKey?: never; shardingError: string; pollingTimeout?: never; + error?: never; } | { status: @@ -240,10 +271,13 @@ export type RootState = { | ShardingStatuses.SHARD_KEY_INVALID | ShardingStatuses.SHARD_KEY_MISMATCH | ShardingStatuses.UNMANAGING_NAMESPACE - | ShardingStatuses.UNMANAGING_NAMESPACE_MISMATCH; + | ShardingStatuses.UNMANAGING_NAMESPACE_MISMATCH + | ShardingStatuses.INCOMPLETE_SHARDING_SETUP + | ShardingStatuses.SUBMITTING_FOR_SHARDING_INCOMPLETE; shardKey: ShardKey; shardingError?: never; pollingTimeout?: never; + error?: never; } ); @@ -264,9 +298,6 @@ const reducer: Reducer = (state = initialState, action) => { return { ...state, managedNamespace: action.managedNamespace, - status: !action.managedNamespace - ? ShardingStatuses.UNSHARDED - : state.status, }; } @@ -296,20 +327,43 @@ const reducer: Reducer = (state = initialState, action) => { GlobalWritesActionTypes.NamespaceShardKeyFetched ) && (state.status === ShardingStatuses.NOT_READY || - state.status === ShardingStatuses.SHARDING) + state.status === ShardingStatuses.SHARDING) && + action.shardKey ) { if (state.pollingTimeout) { throw new Error('Polling was not stopped'); } return { ...state, - status: getStatusFromShardKey(action.shardKey, state.managedNamespace), + status: getStatusFromShardKeyAndManaged( + action.shardKey, + state.managedNamespace + ), shardKey: action.shardKey, shardingError: undefined, pollingTimeout: state.pollingTimeout, }; } + if ( + isAction( + action, + GlobalWritesActionTypes.NamespaceShardKeyFetched + ) && + state.status === ShardingStatuses.NOT_READY && + !action.shardKey && + !state.managedNamespace + ) { + if (state.pollingTimeout) { + throw new Error('Polling was not stopped'); + } + return { + ...state, + status: ShardingStatuses.UNSHARDED, + pollingTimeout: state.pollingTimeout, + }; + } + if ( isAction( action, @@ -348,6 +402,19 @@ const reducer: Reducer = (state = initialState, action) => { }; } + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingStarted + ) && + state.status === ShardingStatuses.INCOMPLETE_SHARDING_SETUP + ) { + return { + ...state, + status: ShardingStatuses.SUBMITTING_FOR_SHARDING_INCOMPLETE, + }; + } + if ( isAction( action, @@ -355,6 +422,7 @@ const reducer: Reducer = (state = initialState, action) => { ) && (state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING || state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR || + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_INCOMPLETE || state.status === ShardingStatuses.NOT_READY) ) { return { @@ -448,7 +516,9 @@ const reducer: Reducer = (state = initialState, action) => { ) { return { ...state, - status: ShardingStatuses.UNSHARDED, + status: state.shardKey + ? ShardingStatuses.INCOMPLETE_SHARDING_SETUP + : ShardingStatuses.UNSHARDED, shardingError: undefined, }; } @@ -467,6 +537,34 @@ const reducer: Reducer = (state = initialState, action) => { }; } + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingErrored + ) && + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR + ) { + return { + ...state, + managedNamespace: undefined, + status: ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingErrored + ) && + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_INCOMPLETE + ) { + return { + ...state, + managedNamespace: undefined, + status: ShardingStatuses.INCOMPLETE_SHARDING_SETUP, + }; + } + if ( isAction( action, @@ -495,7 +593,7 @@ const reducer: Reducer = (state = initialState, action) => { return { ...state, managedNamespace: undefined, - status: ShardingStatuses.UNSHARDED, + status: ShardingStatuses.INCOMPLETE_SHARDING_SETUP, }; } @@ -512,6 +610,25 @@ const reducer: Reducer = (state = initialState, action) => { }; } + if ( + isAction( + action, + GlobalWritesActionTypes.LoadingFailed + ) && + (state.status === ShardingStatuses.NOT_READY || + state.status === ShardingStatuses.SHARDING) + ) { + if (state.pollingTimeout) { + throw new Error('Polling was not stopped'); + } + return { + ...state, + status: ShardingStatuses.LOADING_ERROR, + error: action.error, + pollingTimeout: state.pollingTimeout, + }; + } + return state; }; @@ -524,9 +641,6 @@ export const fetchClusterShardingData = ) => { const { namespace } = getState(); try { - // Call the API to check if the namespace is managed. If the namespace is managed, - // we would want to fetch more data that is needed to figure out the state and - // accordingly show the UI to the user. const managedNamespace = await atlasGlobalWritesService.getManagedNamespace(namespace); @@ -534,11 +648,7 @@ export const fetchClusterShardingData = type: GlobalWritesActionTypes.ManagedNamespaceFetched, managedNamespace, }); - if (!managedNamespace) { - return; - } - // At this point, the namespace is managed and we want to fetch the sharding key. void dispatch(fetchNamespaceShardKey()); } catch (error) { logger.log.error( @@ -547,20 +657,31 @@ export const fetchClusterShardingData = 'Error fetching cluster sharding data', (error as Error).message ); - openToast( - `global-writes-fetch-shard-info-error-${connectionInfoRef.current.id}-${namespace}`, - { - title: `Failed to fetch sharding information: ${ - (error as Error).message - }`, - dismissible: true, - timeout: 5000, - variant: 'important', - } - ); + handleLoadingError({ + error: error as Error, + id: `global-writes-fetch-shard-info-error-${connectionInfoRef.current.id}-${namespace}`, + description: 'Failed to fetch sharding information', + }); } }; +export const resumeManagedNamespace = (): ReturnType => { + return async (dispatch, getState) => { + const { shardKey } = getState(); + if (!shardKey) { + throw new Error('Cannot resume managed namespace without a shardKey'); + } + const data: CreateShardKeyData = { + customShardKey: shardKey.fields[1].name, + isShardKeyUnique: shardKey.isUnique, + isCustomShardKeyHashed: shardKey.fields[1].type === 'HASHED', + numInitialChunks: null, // default + presplitHashedZones: false, // default + }; + await dispatch(createShardKey(data)); + }; +}; + export const createShardKey = ( data: CreateShardKeyData ): GlobalWritesThunkAction< @@ -580,7 +701,7 @@ export const createShardKey = ( }); try { - const managedNamespace = await atlasGlobalWritesService.createShardKey( + const managedNamespace = await atlasGlobalWritesService.manageNamespace( namespace, data ); @@ -715,6 +836,39 @@ const stopPollingForShardKey = (): GlobalWritesThunkAction< }; }; +const handleLoadingError = ({ + error, + id, + description, +}: { + error: Error; + id: string; + description: string; +}): GlobalWritesThunkAction => { + return (dispatch, getState) => { + const { status } = getState(); + const isPolling = status === ShardingStatuses.SHARDING; + const isInitialLoad = status === ShardingStatuses.NOT_READY; + const errorMessage = `${description} ${error.message}`; + if (isInitialLoad || isPolling) { + if (isPolling) { + dispatch(stopPollingForShardKey()); + } + dispatch({ + type: GlobalWritesActionTypes.LoadingFailed, + error: errorMessage, + }); + return; + } + openToast(id, { + title: errorMessage, + dismissible: true, + timeout: 5000, + variant: 'important', + }); + }; +}; + export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< Promise, NamespaceShardingErrorFetchedAction | NamespaceShardKeyFetchedAction @@ -724,7 +878,7 @@ export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< getState, { atlasGlobalWritesService, logger, connectionInfoRef } ) => { - const { namespace, status } = getState(); + const { namespace, status, managedNamespace } = getState(); try { const [shardingError, shardKey] = await Promise.all([ @@ -732,7 +886,15 @@ export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< atlasGlobalWritesService.getShardingKeys(namespace), ]); - if (shardingError && !shardKey) { + if (status === ShardingStatuses.SHARDING && (shardKey || shardingError)) { + dispatch(stopPollingForShardKey()); + } + + if (managedNamespace && !shardKey) { + if (!shardingError) { + dispatch(setNamespaceBeingSharded()); + return; + } // if there is an existing shard key and an error both, // means we have a key mismatch // this will be handled in NamespaceShardKeyFetched @@ -746,18 +908,12 @@ export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< return; } - if (!shardKey) { - dispatch(setNamespaceBeingSharded()); - return; - } - - if (status === ShardingStatuses.SHARDING) { - dispatch(stopPollingForShardKey()); - } dispatch({ type: GlobalWritesActionTypes.NamespaceShardKeyFetched, shardKey, }); + // if there is a key, we fetch sharding zones + if (!shardKey) return; void dispatch(fetchShardingZones()); } catch (error) { logger.log.error( @@ -766,15 +922,11 @@ export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< 'Error fetching shard key', (error as Error).message ); - openToast( - `global-writes-fetch-shard-key-error-${connectionInfoRef.current.id}-${namespace}`, - { - title: `Failed to fetch shard key: ${(error as Error).message}`, - dismissible: true, - timeout: 5000, - variant: 'important', - } - ); + handleLoadingError({ + error: error as Error, + id: `global-writes-fetch-shard-key-error-${connectionInfoRef.current.id}-${namespace}`, + description: 'Failed to fetch shard key', + }); } }; }; @@ -835,7 +987,7 @@ export const unmanageNamespace = (): GlobalWritesThunkAction< }; }; -export function getStatusFromShardKey( +export function getStatusFromShardKeyAndManaged( shardKey: ShardKey, managedNamespace?: ManagedNamespace ) { @@ -848,6 +1000,15 @@ export function getStatusFromShardKey( const isLocatonKeyValid = firstShardKey.name === 'location' && firstShardKey.type === 'RANGE'; + + if (!isLocatonKeyValid || !secondShardKey) { + return ShardingStatuses.SHARD_KEY_INVALID; + } + + if (!managedNamespace) { + return ShardingStatuses.INCOMPLETE_SHARDING_SETUP; + } + const isCustomKeyValid = managedNamespace && managedNamespace.isShardKeyUnique === shardKey.isUnique && @@ -855,10 +1016,6 @@ export function getStatusFromShardKey( secondShardKey.type === (managedNamespace.isCustomShardKeyHashed ? 'HASHED' : 'RANGE'); - if (!isLocatonKeyValid || !secondShardKey) { - return ShardingStatuses.SHARD_KEY_INVALID; - } - if (!isCustomKeyValid) { return ShardingStatuses.SHARD_KEY_MISMATCH; }