Skip to content

Commit 6a8726b

Browse files
authored
[7.x] Resilient saved object migration algorithm (#78413) (#86284)
* Resilient saved object migration algorithm (#78413) * Initial structure of migration state-action machine * Fix type import * Retries with exponential back off * Use discriminated union for state type * Either type for actions * Test exponential retries * TaskEither types for actions * Fetch indices instead of aliases so we can collect all index state in one request * Log document id if transform fails * WIP: Legacy pre-migrations * UPDATE_TARGET_MAPPINGS * WIP OUTDATED_DOCUMENTS_TRANSFORM * Narrow res types depending on control state * OUTDATED_DOCUMENTS_TRANSFORM * Use .kibana instead of .kibana_current * rename control states TARGET_DOCUMENTS* -> OUTDATED_DOCUMENTS* * WIP MARK_VERSION_INDEX_READY * Fix and expand INIT -> * transition tests * Add alias/index name helper functions * Add feature flag for enabling v2 migrations * split state_action_machine, reindex legacy indices * Don't use a scroll search for migrating outdated documents * model: test control state progressions * Action integration tests * Fix existing tests and type errors * snapshot_in_progress_exception can only happen when closing/deleting an index * Retry steps up to 10 times * Update api.md documentation files * Further actions integration tests * Action unit tests * Fix actions integration tests * Rename actions to be more domain-specific * Apply suggestions from code review Co-authored-by: Josh Dover <me@joshdover.com> * Review feedback: polish and flesh out inline comments * Fix unhandled rejections in actions unit tests * model: only delay retryable_es_client_error, reset for other left responses * Actions unit tests * More inline comments * Actions: Group index settings under 'index' key * bulkIndex -> bulkOverwriteTransformedDocuments to be more domain specific * state_action_machine tests, fix and add additional tests * Action integration tests: updateAndPickupMappings, searchForOutdatedDocuments * oops: uncomment commented out code * actions integration tests: rejection for createIndex * update state properties: clearer names, mark all as readonly * add state properties currentAlias, versionAlias, legacyIndex and test for invalid version scheme in index names * Use CONSTANTS for constants :D * Actions: Clarify behaviour and impact of acknowledged: false responses * Use consistent vocabulary for action responses * KibanaMigrator test for migrationsV2 * KibanaMigrator test for FATAL state and action exceptions in v2 migrations * Fix ts error in test * Refactor: split index file up into a file per model, next, types * next: use partial application so we don't generate a nextActionMap on every call * move logic from index.ts to migrations_state_action_machine.ts and test * add test * use `Root` to allow specifying oss mode * Add fix and todo tests for reindexing with preMigrationScript * Dump execution log of state transitions and responses if we hit FATAL * add 7.3 xpack tests * add 100k test data * Reindex instead of cloning for migrations * Skip 100k x-pack integration test * MARK_VERSION_INDEX_READY_CONFLICT for dealing with different versions migrating in parallel * Track elapsed time * Fix tests * Model: make exhaustiveness checks more explicit * actions integration tests: add additional tests from CR * migrations_state_action_machine fix flaky test * Fix flaky integration test * Reserve FATAL termination only for situations which we never can recover from such as later version already migrated the index * Handle incompatible_mapping_exception caused by another instance * Cleanup logging * Fix/stabilize integration tests * Add REINDEX_SOURCE_TO_TARGET_VERIFY step * Strip tests archives of */.DS_Store and __MAC_OSX * Task manager migrations: remove invalid kibana property when converting legacy indices * Add disabled mappings for removed field in map saved object type * verifyReindex action: use count API * REINDEX_BLOCK_* to prevent lost deletes (needs tests) * Split out 100k docs integration test so that it has it's own kibana process * REINDEX_BLOCK_* action tests * REINDEX_BLOCK_* model tests * Include original error message when migration_state_machine throws * Address some CR nits * Fix TS errors * Fix bugs * Reindex then clone to prevent lost deletes * Fix tests Co-authored-by: Josh Dover <me@joshdover.com> Co-authored-by: pgayvallet <pierre.gayvallet@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> # Conflicts: # rfcs/text/0013_saved_object_migrations.md * createRootWithCorePlugins support for cliArgs which wasn't backported to 7.x * Attempt to stabilize cloneIndex integration tests (#86123) * Attempt to stabilize cloneIndex integration tests * Unskip test * return resolves/rejects and add assertions counts to each test * Await don't return expect promises * Await don't return expect promises for other tests too * Remove 8.0.0 integration test
1 parent d20bb3d commit 6a8726b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+6367
-124
lines changed

docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdoc._type.md

Lines changed: 0 additions & 11 deletions
This file was deleted.

docs/development/core/server/kibana-plugin-core-server.savedobjectsrawdoc.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,4 @@ export interface SavedObjectsRawDoc
2020
| [\_primary\_term](./kibana-plugin-core-server.savedobjectsrawdoc._primary_term.md) | <code>number</code> | |
2121
| [\_seq\_no](./kibana-plugin-core-server.savedobjectsrawdoc._seq_no.md) | <code>number</code> | |
2222
| [\_source](./kibana-plugin-core-server.savedobjectsrawdoc._source.md) | <code>SavedObjectsRawDocSource</code> | |
23-
| [\_type](./kibana-plugin-core-server.savedobjectsrawdoc._type.md) | <code>string</code> | |
2423

src/core/public/public.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { Action } from 'history';
88
import { ApiResponse } from '@elastic/elasticsearch/lib/Transport';
99
import Boom from '@hapi/boom';
10+
import { ConfigDeprecationProvider } from '@kbn/config';
1011
import { ConfigPath } from '@kbn/config';
1112
import { EnvironmentMode } from '@kbn/config';
1213
import { EuiBreadcrumb } from '@elastic/eui';
@@ -18,7 +19,6 @@ import { History } from 'history';
1819
import { Href } from 'history';
1920
import { IconType } from '@elastic/eui';
2021
import { KibanaClient } from '@elastic/elasticsearch/api/kibana';
21-
import { KibanaConfigType } from 'src/core/server/kibana_config';
2222
import { Location } from 'history';
2323
import { LocationDescriptorObject } from 'history';
2424
import { Logger } from '@kbn/logging';

src/core/server/elasticsearch/client/mocks.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest';
2222
import { ElasticsearchClient } from './types';
2323
import { ICustomClusterClient } from './cluster_client';
2424

25-
const createInternalClientMock = (): DeeplyMockedKeys<Client> => {
25+
const createInternalClientMock = (
26+
res?: MockedTransportRequestPromise<unknown>
27+
): DeeplyMockedKeys<Client> => {
2628
// we mimic 'reflection' on a concrete instance of the client to generate the mocked functions.
2729
const client = new Client({
2830
node: 'http://localhost',
@@ -59,7 +61,7 @@ const createInternalClientMock = (): DeeplyMockedKeys<Client> => {
5961
.filter(([key]) => !omitted.includes(key))
6062
.forEach(([key, descriptor]) => {
6163
if (typeof descriptor.value === 'function') {
62-
obj[key] = jest.fn(() => createSuccessTransportRequestPromise({}));
64+
obj[key] = jest.fn(() => res ?? createSuccessTransportRequestPromise({}));
6365
} else if (typeof obj[key] === 'object' && obj[key] != null) {
6466
mockify(obj[key], omitted);
6567
}
@@ -95,8 +97,8 @@ const createInternalClientMock = (): DeeplyMockedKeys<Client> => {
9597

9698
export type ElasticsearchClientMock = DeeplyMockedKeys<ElasticsearchClient>;
9799

98-
const createClientMock = (): ElasticsearchClientMock =>
99-
(createInternalClientMock() as unknown) as ElasticsearchClientMock;
100+
const createClientMock = (res?: MockedTransportRequestPromise<unknown>): ElasticsearchClientMock =>
101+
(createInternalClientMock(res) as unknown) as ElasticsearchClientMock;
100102

101103
export interface ScopedClusterClientMock {
102104
asInternalUser: ElasticsearchClientMock;

src/core/server/saved_objects/migrations/core/document_migrator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ function wrapWithTry(
312312
const failedTransform = `${type}:${version}`;
313313
const failedDoc = JSON.stringify(doc);
314314
log.warn(
315-
`Failed to transform document ${doc}. Transform: ${failedTransform}\nDoc: ${failedDoc}`
315+
`Failed to transform document ${doc?.id}. Transform: ${failedTransform}\nDoc: ${failedDoc}`
316316
);
317317
throw error;
318318
}

src/core/server/saved_objects/migrations/core/migration_context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
* serves as a central blueprint for what migrations will end up doing.
2525
*/
2626

27-
import { Logger } from 'src/core/server/logging';
27+
import { Logger } from '../../../logging';
2828
import { MigrationEsClient } from './migration_es_client';
2929
import { SavedObjectsSerializer } from '../../serialization';
3030
import {

src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import type { PublicMethodsOf } from '@kbn/utility-types';
20-
21-
import { KibanaMigrator, KibanaMigratorStatus } from './kibana_migrator';
19+
import { IKibanaMigrator, KibanaMigratorStatus } from './kibana_migrator';
2220
import { buildActiveMappings } from '../core';
2321
const { mergeTypes } = jest.requireActual('./kibana_migrator');
2422
import { SavedObjectsType } from '../../types';
@@ -45,7 +43,16 @@ const createMigrator = (
4543
types: SavedObjectsType[];
4644
} = { types: defaultSavedObjectTypes }
4745
) => {
48-
const mockMigrator: jest.Mocked<PublicMethodsOf<KibanaMigrator>> = {
46+
const mockMigrator: jest.Mocked<IKibanaMigrator> = {
47+
kibanaVersion: '8.0.0-testing',
48+
savedObjectsConfig: {
49+
batchSize: 100,
50+
scrollDuration: '15m',
51+
pollInterval: 1500,
52+
skip: false,
53+
// TODO migrationsV2: remove/deprecate once we release migrations v2
54+
enableV2: false,
55+
},
4956
runMigrations: jest.fn(),
5057
getActiveMappings: jest.fn(),
5158
migrateDocument: jest.fn(),

src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts

Lines changed: 216 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator';
2323
import { loggingSystemMock } from '../../../logging/logging_system.mock';
2424
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
2525
import { SavedObjectsType } from '../../types';
26+
import { errors as esErrors } from '@elastic/elasticsearch';
2627

2728
const createRegistry = (types: Array<Partial<SavedObjectsType>>) => {
2829
const registry = new SavedObjectTypeRegistry();
@@ -89,38 +90,188 @@ describe('KibanaMigrator', () => {
8990
expect(options.client.cat.templates).toHaveBeenCalledTimes(1);
9091
});
9192

92-
it('emits results on getMigratorResult$()', async () => {
93-
const options = mockOptions();
93+
describe('when enableV2 = false', () => {
94+
it('when enableV2 = false creates an IndexMigrator which retries NoLivingConnectionsError errors from ES client', async () => {
95+
const options = mockOptions();
9496

95-
options.client.cat.templates.mockReturnValue(
96-
elasticsearchClientMock.createSuccessTransportRequestPromise(
97-
{ templates: [] },
98-
{ statusCode: 404 }
99-
)
100-
);
101-
options.client.indices.get.mockReturnValue(
102-
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 })
103-
);
104-
options.client.indices.getAlias.mockReturnValue(
105-
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 })
106-
);
97+
options.client.cat.templates.mockReturnValue(
98+
elasticsearchClientMock.createSuccessTransportRequestPromise(
99+
{ templates: [] },
100+
{ statusCode: 404 }
101+
)
102+
);
103+
options.client.indices.get.mockReturnValue(
104+
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 })
105+
);
106+
options.client.indices.getAlias.mockReturnValue(
107+
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 })
108+
);
107109

108-
const migrator = new KibanaMigrator(options);
109-
const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise();
110-
await migrator.runMigrations();
111-
const { status, result } = await migratorStatus;
112-
expect(status).toEqual('completed');
113-
expect(result![0]).toMatchObject({
114-
destIndex: '.my-index_1',
115-
elapsedMs: expect.any(Number),
116-
sourceIndex: '.my-index',
117-
status: 'migrated',
110+
options.client.indices.create = jest
111+
.fn()
112+
.mockReturnValueOnce(
113+
elasticsearchClientMock.createErrorTransportRequestPromise(
114+
new esErrors.NoLivingConnectionsError('reason', {} as any)
115+
)
116+
)
117+
.mockImplementationOnce(() =>
118+
elasticsearchClientMock.createSuccessTransportRequestPromise('success')
119+
);
120+
121+
const migrator = new KibanaMigrator(options);
122+
const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise();
123+
await migrator.runMigrations();
124+
125+
expect(options.client.indices.create).toHaveBeenCalledTimes(3);
126+
const { status } = await migratorStatus;
127+
return expect(status).toEqual('completed');
128+
});
129+
130+
it('emits results on getMigratorResult$()', async () => {
131+
const options = mockOptions();
132+
133+
options.client.cat.templates.mockReturnValue(
134+
elasticsearchClientMock.createSuccessTransportRequestPromise(
135+
{ templates: [] },
136+
{ statusCode: 404 }
137+
)
138+
);
139+
options.client.indices.get.mockReturnValue(
140+
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 })
141+
);
142+
options.client.indices.getAlias.mockReturnValue(
143+
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 })
144+
);
145+
146+
const migrator = new KibanaMigrator(options);
147+
const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise();
148+
await migrator.runMigrations();
149+
const { status, result } = await migratorStatus;
150+
expect(status).toEqual('completed');
151+
expect(result![0]).toMatchObject({
152+
destIndex: '.my-index_1',
153+
elapsedMs: expect.any(Number),
154+
sourceIndex: '.my-index',
155+
status: 'migrated',
156+
});
157+
expect(result![1]).toMatchObject({
158+
destIndex: 'other-index_1',
159+
elapsedMs: expect.any(Number),
160+
sourceIndex: 'other-index',
161+
status: 'migrated',
162+
});
163+
});
164+
});
165+
describe('when enableV2 = true', () => {
166+
beforeEach(() => {
167+
jest.clearAllMocks();
118168
});
119-
expect(result![1]).toMatchObject({
120-
destIndex: 'other-index_1',
121-
elapsedMs: expect.any(Number),
122-
sourceIndex: 'other-index',
123-
status: 'migrated',
169+
170+
it('creates a V2 migrator that initializes a new index and migrates an existing index', async () => {
171+
const options = mockV2MigrationOptions();
172+
const migrator = new KibanaMigrator(options);
173+
const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise();
174+
await migrator.runMigrations();
175+
176+
// Basic assertions that we're creating and reindexing the expected indices
177+
expect(options.client.indices.create).toHaveBeenCalledTimes(3);
178+
expect(options.client.indices.create.mock.calls).toEqual(
179+
expect.arrayContaining([
180+
// LEGACY_CREATE_REINDEX_TARGET
181+
expect.arrayContaining([expect.objectContaining({ index: '.my-index_pre8.2.3_001' })]),
182+
// CREATE_REINDEX_TEMP
183+
expect.arrayContaining([
184+
expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }),
185+
]),
186+
// CREATE_NEW_TARGET
187+
expect.arrayContaining([expect.objectContaining({ index: 'other-index_8.2.3_001' })]),
188+
])
189+
);
190+
// LEGACY_REINDEX
191+
expect(options.client.reindex.mock.calls[0][0]).toEqual(
192+
expect.objectContaining({
193+
body: expect.objectContaining({
194+
source: expect.objectContaining({ index: '.my-index' }),
195+
dest: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }),
196+
}),
197+
})
198+
);
199+
// REINDEX_SOURCE_TO_TEMP
200+
expect(options.client.reindex.mock.calls[1][0]).toEqual(
201+
expect.objectContaining({
202+
body: expect.objectContaining({
203+
source: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }),
204+
dest: expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }),
205+
}),
206+
})
207+
);
208+
const { status } = await migratorStatus;
209+
return expect(status).toEqual('completed');
210+
});
211+
it('emits results on getMigratorResult$()', async () => {
212+
const options = mockV2MigrationOptions();
213+
const migrator = new KibanaMigrator(options);
214+
const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise();
215+
await migrator.runMigrations();
216+
217+
const { status, result } = await migratorStatus;
218+
expect(status).toEqual('completed');
219+
expect(result![0]).toMatchObject({
220+
destIndex: '.my-index_8.2.3_001',
221+
sourceIndex: '.my-index_pre8.2.3_001',
222+
elapsedMs: expect.any(Number),
223+
status: 'migrated',
224+
});
225+
expect(result![1]).toMatchObject({
226+
destIndex: 'other-index_8.2.3_001',
227+
elapsedMs: expect.any(Number),
228+
status: 'patched',
229+
});
230+
});
231+
it('rejects when the migration state machine terminates in a FATAL state', () => {
232+
const options = mockV2MigrationOptions();
233+
options.client.indices.get.mockReturnValue(
234+
elasticsearchClientMock.createSuccessTransportRequestPromise(
235+
{
236+
'.my-index_8.2.4_001': {
237+
aliases: {
238+
'.my-index': {},
239+
'.my-index_8.2.4': {},
240+
},
241+
mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } },
242+
settings: {},
243+
},
244+
},
245+
{ statusCode: 200 }
246+
)
247+
);
248+
249+
const migrator = new KibanaMigrator(options);
250+
return expect(migrator.runMigrations()).rejects.toMatchInlineSnapshot(
251+
`[Error: Unable to complete saved object migrations for the [.my-index] index: The .my-index alias is pointing to a newer version of Kibana: v8.2.4]`
252+
);
253+
});
254+
it('rejects when an unexpected exception occurs in an action', async () => {
255+
const options = mockV2MigrationOptions();
256+
options.client.tasks.get.mockReturnValue(
257+
elasticsearchClientMock.createSuccessTransportRequestPromise({
258+
completed: true,
259+
error: { type: 'elatsicsearch_exception', reason: 'task failed with an error' },
260+
failures: [],
261+
task: { description: 'task description' },
262+
})
263+
);
264+
265+
const migrator = new KibanaMigrator(options);
266+
267+
await expect(migrator.runMigrations()).rejects.toMatchInlineSnapshot(`
268+
[Error: Unable to complete saved object migrations for the [.my-index] index. Please check the health of your Elasticsearch cluster and try again. Error: Reindex failed with the following error:
269+
{"_tag":"Some","value":{"type":"elatsicsearch_exception","reason":"task failed with an error"}}]
270+
`);
271+
expect(loggingSystemMock.collect(options.logger).error[0][0]).toMatchInlineSnapshot(`
272+
[Error: Reindex failed with the following error:
273+
{"_tag":"Some","value":{"type":"elatsicsearch_exception","reason":"task failed with an error"}}]
274+
`);
124275
});
125276
});
126277
});
@@ -130,7 +281,40 @@ type MockedOptions = KibanaMigratorOptions & {
130281
client: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
131282
};
132283

133-
const mockOptions = () => {
284+
const mockV2MigrationOptions = () => {
285+
const options = mockOptions({ enableV2: true });
286+
287+
options.client.indices.get.mockReturnValue(
288+
elasticsearchClientMock.createSuccessTransportRequestPromise(
289+
{
290+
'.my-index': {
291+
aliases: { '.kibana': {} },
292+
mappings: { properties: {} },
293+
settings: {},
294+
},
295+
},
296+
{ statusCode: 200 }
297+
)
298+
);
299+
options.client.indices.addBlock.mockReturnValue(
300+
elasticsearchClientMock.createSuccessTransportRequestPromise({ acknowledged: true })
301+
);
302+
options.client.reindex.mockReturnValue(
303+
elasticsearchClientMock.createSuccessTransportRequestPromise({ taskId: 'reindex_task_id' })
304+
);
305+
options.client.tasks.get.mockReturnValue(
306+
elasticsearchClientMock.createSuccessTransportRequestPromise({
307+
completed: true,
308+
error: undefined,
309+
failures: [],
310+
task: { description: 'task description' },
311+
})
312+
);
313+
314+
return options;
315+
};
316+
317+
const mockOptions = ({ enableV2 }: { enableV2: boolean } = { enableV2: false }) => {
134318
const options: MockedOptions = {
135319
logger: loggingSystemMock.create().get(),
136320
kibanaVersion: '8.2.3',
@@ -144,7 +328,7 @@ const mockOptions = () => {
144328
name: { type: 'keyword' },
145329
},
146330
},
147-
migrations: {},
331+
migrations: { '8.2.3': jest.fn().mockImplementation((doc) => doc) },
148332
},
149333
{
150334
name: 'testtype2',
@@ -168,6 +352,7 @@ const mockOptions = () => {
168352
pollInterval: 20000,
169353
scrollDuration: '10m',
170354
skip: false,
355+
enableV2,
171356
},
172357
client: elasticsearchClientMock.createElasticsearchClient(),
173358
};

0 commit comments

Comments
 (0)