[8.6] Reduce startup time by skipping update mappings step when possible (#145604) (#146637)

# Backport

This will backport the following commits from `main` to `8.6`:
- [Reduce startup time by skipping update mappings step when possible
(#145604)](https://github.com/elastic/kibana/pull/145604)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Gerard
Soldevila","email":"gerard.soldevila@elastic.co"},"sourceCommit":{"committedDate":"2022-11-28T14:34:58Z","message":"Reduce
startup time by skipping update mappings step when possible
(#145604)\n\nThe goal of this PR is to reduce the startup times of
Kibana server by\r\nimproving the migration logic.\r\n\r\nFixes
https://github.com/elastic/kibana/issues/145743\r\nRelated
https://github.com/elastic/kibana/issues/144035)\r\n\r\nThe migration
logic is run systematically at startup, whether the\r\ncustomers are
upgrading or not.\r\nHistorically, these steps have been very quick, but
we recently found\r\nout about some customers that have more than **one
million** Saved\r\nObjects stored, making the overall startup process
slow, even when there\r\nare no migrations to perform.\r\n\r\nThis PR
specifically targets the case where there are no migrations
to\r\nperform, aka a Kibana node is started against an ES cluster that
is\r\nalready up to date wrt stack version and list of
plugins.\r\n\r\nIn this scenario, we aim at skipping the
`UPDATE_TARGET_MAPPINGS` step\r\nof the migration logic, which
internally runs the\r\n`updateAndPickupMappings` method, which turns out
to be expensive if the\r\nsystem indices contain lots of
SO.\r\n\r\n\r\nI locally tested the following scenarios too:\r\n\r\n-
**Fresh install.** The step is not even run, as the `.kibana`
index\r\ndid not exist \r\n- **Stack version + list of plugins up to
date.** Simply restarting\r\nKibana after the fresh install. The step is
run and leads to `DONE`, as\r\nthe md5 hashes match those stored in
`.kibana._mapping._meta` \r\n- **Faking re-enabling an old plugin.** I
manually removed one of the\r\nMD5 hashes from the stored
.kibana._mapping._meta through `curl`, and\r\nthen restarted Kibana. The
step is run and leads to\r\n`UPDATE_TARGET_MAPPINGS` as it used to
before the PR \r\n- **Faking updating a plugin.** Same as the previous
one, but altering\r\nan existing md5 stored in the metas. \r\n\r\nAnd
that is the curl command used to tamper with the stored
_meta:\r\n```bash\r\ncurl -X PUT
\"kibana:changeme@localhost:9200/.kibana/_mapping?pretty\" -H
'Content-Type: application/json' -d'\r\n{\r\n \"_meta\": {\r\n
\"migrationMappingPropertyHashes\": {\r\n \"references\":
\"7997cf5a56cc02bdc9c93361bde732b0\",\r\n }\r\n
}\r\n}\r\n'\r\n```","sha":"b1e18a0414ed99456706119d15173b687c6e7366","branchLabelMapping":{"^v8.7.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Core","enhancement","release_note:skip","Feature:Migrations","backport:prev-minor","v8.7.0"],"number":145604,"url":"https://github.com/elastic/kibana/pull/145604","mergeCommit":{"message":"Reduce
startup time by skipping update mappings step when possible
(#145604)\n\nThe goal of this PR is to reduce the startup times of
Kibana server by\r\nimproving the migration logic.\r\n\r\nFixes
https://github.com/elastic/kibana/issues/145743\r\nRelated
https://github.com/elastic/kibana/issues/144035)\r\n\r\nThe migration
logic is run systematically at startup, whether the\r\ncustomers are
upgrading or not.\r\nHistorically, these steps have been very quick, but
we recently found\r\nout about some customers that have more than **one
million** Saved\r\nObjects stored, making the overall startup process
slow, even when there\r\nare no migrations to perform.\r\n\r\nThis PR
specifically targets the case where there are no migrations
to\r\nperform, aka a Kibana node is started against an ES cluster that
is\r\nalready up to date wrt stack version and list of
plugins.\r\n\r\nIn this scenario, we aim at skipping the
`UPDATE_TARGET_MAPPINGS` step\r\nof the migration logic, which
internally runs the\r\n`updateAndPickupMappings` method, which turns out
to be expensive if the\r\nsystem indices contain lots of
SO.\r\n\r\n\r\nI locally tested the following scenarios too:\r\n\r\n-
**Fresh install.** The step is not even run, as the `.kibana`
index\r\ndid not exist \r\n- **Stack version + list of plugins up to
date.** Simply restarting\r\nKibana after the fresh install. The step is
run and leads to `DONE`, as\r\nthe md5 hashes match those stored in
`.kibana._mapping._meta` \r\n- **Faking re-enabling an old plugin.** I
manually removed one of the\r\nMD5 hashes from the stored
.kibana._mapping._meta through `curl`, and\r\nthen restarted Kibana. The
step is run and leads to\r\n`UPDATE_TARGET_MAPPINGS` as it used to
before the PR \r\n- **Faking updating a plugin.** Same as the previous
one, but altering\r\nan existing md5 stored in the metas. \r\n\r\nAnd
that is the curl command used to tamper with the stored
_meta:\r\n```bash\r\ncurl -X PUT
\"kibana:changeme@localhost:9200/.kibana/_mapping?pretty\" -H
'Content-Type: application/json' -d'\r\n{\r\n \"_meta\": {\r\n
\"migrationMappingPropertyHashes\": {\r\n \"references\":
\"7997cf5a56cc02bdc9c93361bde732b0\",\r\n }\r\n
}\r\n}\r\n'\r\n```","sha":"b1e18a0414ed99456706119d15173b687c6e7366"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.7.0","labelRegex":"^v8.7.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/145604","number":145604,"mergeCommit":{"message":"Reduce
startup time by skipping update mappings step when possible
(#145604)\n\nThe goal of this PR is to reduce the startup times of
Kibana server by\r\nimproving the migration logic.\r\n\r\nFixes
https://github.com/elastic/kibana/issues/145743\r\nRelated
https://github.com/elastic/kibana/issues/144035)\r\n\r\nThe migration
logic is run systematically at startup, whether the\r\ncustomers are
upgrading or not.\r\nHistorically, these steps have been very quick, but
we recently found\r\nout about some customers that have more than **one
million** Saved\r\nObjects stored, making the overall startup process
slow, even when there\r\nare no migrations to perform.\r\n\r\nThis PR
specifically targets the case where there are no migrations
to\r\nperform, aka a Kibana node is started against an ES cluster that
is\r\nalready up to date wrt stack version and list of
plugins.\r\n\r\nIn this scenario, we aim at skipping the
`UPDATE_TARGET_MAPPINGS` step\r\nof the migration logic, which
internally runs the\r\n`updateAndPickupMappings` method, which turns out
to be expensive if the\r\nsystem indices contain lots of
SO.\r\n\r\n\r\nI locally tested the following scenarios too:\r\n\r\n-
**Fresh install.** The step is not even run, as the `.kibana`
index\r\ndid not exist \r\n- **Stack version + list of plugins up to
date.** Simply restarting\r\nKibana after the fresh install. The step is
run and leads to `DONE`, as\r\nthe md5 hashes match those stored in
`.kibana._mapping._meta` \r\n- **Faking re-enabling an old plugin.** I
manually removed one of the\r\nMD5 hashes from the stored
.kibana._mapping._meta through `curl`, and\r\nthen restarted Kibana. The
step is run and leads to\r\n`UPDATE_TARGET_MAPPINGS` as it used to
before the PR \r\n- **Faking updating a plugin.** Same as the previous
one, but altering\r\nan existing md5 stored in the metas. \r\n\r\nAnd
that is the curl command used to tamper with the stored
_meta:\r\n```bash\r\ncurl -X PUT
\"kibana:changeme@localhost:9200/.kibana/_mapping?pretty\" -H
'Content-Type: application/json' -d'\r\n{\r\n \"_meta\": {\r\n
\"migrationMappingPropertyHashes\": {\r\n \"references\":
\"7997cf5a56cc02bdc9c93361bde732b0\",\r\n }\r\n
}\r\n}\r\n'\r\n```","sha":"b1e18a0414ed99456706119d15173b687c6e7366"}}]}]
BACKPORT-->
This commit is contained in:
Gerard Soldevila 2022-11-30 11:26:18 +01:00 committed by GitHub
parent c4c316e846
commit 42bf33f146
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 812 additions and 73 deletions

View file

@ -24,6 +24,7 @@ export {
cloneIndex,
waitForTask,
updateAndPickupMappings,
updateTargetMappingsMeta,
updateAliases,
transformDocs,
setWriteBlock,

View file

@ -21,51 +21,63 @@
- [LEGACY_DELETE](#legacy_delete)
- [Next action](#next-action-6)
- [New control state](#new-control-state-6)
- [WAIT_FOR_YELLOW_SOURCE](#wait_for_yellow_source)
- [WAIT_FOR_MIGRATION_COMPLETION](#wait_for_migration_completion)
- [Next action](#next-action-7)
- [New control state](#new-control-state-7)
- [SET_SOURCE_WRITE_BLOCK](#set_source_write_block)
- [WAIT_FOR_YELLOW_SOURCE](#wait_for_yellow_source)
- [Next action](#next-action-8)
- [New control state](#new-control-state-8)
- [CREATE_REINDEX_TEMP](#create_reindex_temp)
- [SET_SOURCE_WRITE_BLOCK](#set_source_write_block)
- [Next action](#next-action-9)
- [New control state](#new-control-state-9)
- [REINDEX_SOURCE_TO_TEMP_OPEN_PIT](#reindex_source_to_temp_open_pit)
- [CREATE_REINDEX_TEMP](#create_reindex_temp)
- [Next action](#next-action-10)
- [New control state](#new-control-state-10)
- [REINDEX_SOURCE_TO_TEMP_READ](#reindex_source_to_temp_read)
- [REINDEX_SOURCE_TO_TEMP_OPEN_PIT](#reindex_source_to_temp_open_pit)
- [Next action](#next-action-11)
- [New control state](#new-control-state-11)
- [REINDEX_SOURCE_TO_TEMP_TRANSFORM](#REINDEX_SOURCE_TO_TEMP_TRANSFORM)
- [REINDEX_SOURCE_TO_TEMP_READ](#reindex_source_to_temp_read)
- [Next action](#next-action-12)
- [New control state](#new-control-state-12)
- [REINDEX_SOURCE_TO_TEMP_INDEX_BULK](#reindex_source_to_temp_index_bulk)
- [REINDEX_SOURCE_TO_TEMP_TRANSFORM](#reindex_source_to_temp_transform)
- [Next action](#next-action-13)
- [New control state](#new-control-state-13)
- [REINDEX_SOURCE_TO_TEMP_CLOSE_PIT](#reindex_source_to_temp_close_pit)
- [REINDEX_SOURCE_TO_TEMP_INDEX_BULK](#reindex_source_to_temp_index_bulk)
- [Next action](#next-action-14)
- [New control state](#new-control-state-14)
- [SET_TEMP_WRITE_BLOCK](#set_temp_write_block)
- [REINDEX_SOURCE_TO_TEMP_CLOSE_PIT](#reindex_source_to_temp_close_pit)
- [Next action](#next-action-15)
- [New control state](#new-control-state-15)
- [CLONE_TEMP_TO_TARGET](#clone_temp_to_target)
- [SET_TEMP_WRITE_BLOCK](#set_temp_write_block)
- [Next action](#next-action-16)
- [New control state](#new-control-state-16)
- [OUTDATED_DOCUMENTS_SEARCH](#outdated_documents_search)
- [CLONE_TEMP_TO_TARGET](#clone_temp_to_target)
- [Next action](#next-action-17)
- [New control state](#new-control-state-17)
- [OUTDATED_DOCUMENTS_TRANSFORM](#outdated_documents_transform)
- [OUTDATED_DOCUMENTS_SEARCH](#outdated_documents_search)
- [Next action](#next-action-18)
- [New control state](#new-control-state-18)
- [UPDATE_TARGET_MAPPINGS](#update_target_mappings)
- [OUTDATED_DOCUMENTS_TRANSFORM](#outdated_documents_transform)
- [Next action](#next-action-19)
- [New control state](#new-control-state-19)
- [UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK](#update_target_mappings_wait_for_task)
- [CHECK_TARGET_MAPPINGS](#check_target_mappings)
- [Next action](#next-action-20)
- [New control state](#new-control-state-20)
- [MARK_VERSION_INDEX_READY_CONFLICT](#mark_version_index_ready_conflict)
- [UPDATE_TARGET_MAPPINGS](#update_target_mappings)
- [Next action](#next-action-21)
- [New control state](#new-control-state-21)
- [UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK](#update_target_mappings_wait_for_task)
- [Next action](#next-action-22)
- [New control state](#new-control-state-22)
- [CHECK_VERSION_INDEX_READY_ACTIONS](#check_version_index_ready_actions)
- [Next action](#next-action-23)
- [New control state](#new-control-state-23)
- [MARK_VERSION_INDEX_READY](#mark_version_index_ready)
- [Next action](#next-action-24)
- [New control state](#new-control-state-24)
- [MARK_VERSION_INDEX_READY_CONFLICT](#mark_version_index_ready_conflict)
- [Next action](#next-action-25)
- [New control state](#new-control-state-25)
- [Manual QA Test Plan](#manual-qa-test-plan)
- [1. Legacy pre-migration](#1-legacy-pre-migration)
- [2. Plugins enabled/disabled](#2-plugins-enableddisabled)
@ -98,7 +110,7 @@ The design goals for the algorithm was to keep downtime below 10 minutes for
and explicit as possible.
The algorithm is implemented as a state-action machine based on https://www.microsoft.com/en-us/research/uploads/prod/2016/12/Computation-and-State-Machines.pdf
The state-action machine defines it's behaviour in steps. Each step is a
transition from a control state s_i to the contral state s_i+1 caused by an
action a_i.
@ -152,10 +164,10 @@ index.
1. The Elasticsearch shard allocation cluster setting `cluster.routing.allocation.enable` needs to be unset or set to 'all'. When set to 'primaries', 'new_primaries' or 'none', the migration will timeout when waiting for index green status before bulk indexing because the replica cannot be allocated.
As per the Elasticsearch docs https://www.elastic.co/guide/en/elasticsearch/reference/8.2/restart-cluster.html#restart-cluster-rolling when Cloud performs a rolling restart such as during an upgrade, it will temporarily disable shard allocation. Kibana therefore keeps retrying the INIT step to wait for shard allocation to be enabled again.
The check only considers persistent and transient settings and does not take static configuration in `elasticsearch.yml` into account since there are no known use cases for doing so. If `cluster.routing.allocation.enable` is configured in `elaticsearch.yml` and not set to the default of 'all', the migration will timeout. Static settings can only be returned from the `nodes/info` API.
`INIT`
2. If `.kibana` is pointing to an index that belongs to a later version of
Kibana .e.g. a 7.11.0 instance found the `.kibana` alias pointing to
`.kibana_7.12.0_001` fail the migration
@ -271,8 +283,8 @@ new `.kibana` alias that points to `.kibana_pre6.5.0_001`.
1. If the ui node finished the migration
`DONE`
2. Otherwise wait 2s and check again
→ WAIT_FOR_MIGRATION_COMPLETION
`WAIT_FOR_MIGRATION_COMPLETION`
## WAIT_FOR_YELLOW_SOURCE
### Next action
`waitForIndexStatus` (status='yellow')
@ -284,7 +296,7 @@ Wait for the source index to become yellow. This means the index's primary has b
`SET_SOURCE_WRITE_BLOCK`
2. If the action fails with a `index_not_yellow_timeout`
`WAIT_FOR_YELLOW_SOURCE`
## SET_SOURCE_WRITE_BLOCK
### Next action
`setWriteBlock`
@ -298,7 +310,7 @@ Set a write block on the source index to prevent any older Kibana instances from
### Next action
`createIndex`
This operation is idempotent, if the index already exist, we wait until its status turns green.
This operation is idempotent, if the index already exist, we wait until its status turns green.
- Because we will be transforming documents before writing them into this index, we can already set the mappings to the target mappings for this version. The source index might contain documents belonging to a disabled plugin. So set `dynamic: false` mappings for any unknown saved object types.
- (Since we never query the temporary index we can potentially disable refresh to speed up indexing performance. Profile to see if gains justify complexity)
@ -334,7 +346,7 @@ Read the next batch of outdated documents from the source index by using search
`transformRawDocs`
Transform the current batch of documents
In order to support sharing saved objects to multiple spaces in 8.0, the
transforms will also regenerate document `_id`'s. To ensure that this step
remains idempotent, the new `_id` is deterministically generated using UUIDv5
@ -361,7 +373,7 @@ completed this step:
`REINDEX_SOURCE_TO_TEMP_READ`
2. If there are more batches left in `transformedDocBatches`
`REINDEX_SOURCE_TO_TEMP_INDEX_BULK`
## REINDEX_SOURCE_TO_TEMP_CLOSE_PIT
### Next action
`closePIT`
@ -376,7 +388,7 @@ completed this step:
Set a write block on the temporary index so that we can clone it.
### New control state
`CLONE_TEMP_TO_TARGET`
## CLONE_TEMP_TO_TARGET
### Next action
`cloneIndex`
@ -419,6 +431,21 @@ Once transformed we use an index operation to overwrite the outdated document wi
### New control state
`OUTDATED_DOCUMENTS_SEARCH`
## CHECK_TARGET_MAPPINGS
### Next action
`checkTargetMappings`
Compare the calculated mappings' hashes against those stored in the `<index>.mappings._meta`.
### New control state
1. If calculated mappings don't match, we must update them.
`UPDATE_TARGET_MAPPINGS`
2. If calculated mappings and stored mappings match, we can skip directly to the next step.
`CHECK_VERSION_INDEX_READY_ACTIONS`
## UPDATE_TARGET_MAPPINGS
### Next action
`updateAndPickupMappings`
@ -436,6 +463,21 @@ update the mappings and then use an update_by_query to ensure that all fields ar
### New control state
`MARK_VERSION_INDEX_READY`
## CHECK_VERSION_INDEX_READY_ACTIONS
Check if the state contains some `versionIndexReadyActions` from the `INIT` action.
### Next action
None
### New control state
1. If there are some `versionIndexReadyActions`, we performed a full migration and need to point the aliases to our newly migrated index.
`MARK_VERSION_INDEX_READY`
2. If there are no `versionIndexReadyActions`, another instance already completed this migration and we only transformed outdated documents and updated the mappings for in case a new plugin was enabled.
`DONE`
## MARK_VERSION_INDEX_READY
### Next action
`updateAliases`
@ -552,4 +594,3 @@ have data loss when there's a user error.
other half.
5. Ensure that the document from step (2) has been migrated
(`migrationVersion` contains 7.11.0)

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as Either from 'fp-ts/lib/Either';
import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
import { checkTargetMappings } from './check_target_mappings';
import { diffMappings } from '../core/build_active_mappings';
jest.mock('../core/build_active_mappings');
const diffMappingsMock = diffMappings as jest.MockedFn<typeof diffMappings>;
const sourceIndexMappings: IndexMapping = {
properties: {
field: { type: 'integer' },
},
};
const targetIndexMappings: IndexMapping = {
properties: {
field: { type: 'long' },
},
};
describe('checkTargetMappings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns match=false if source mappings are not defined', async () => {
const task = checkTargetMappings({
targetIndexMappings,
});
const result = await task();
expect(diffMappings).not.toHaveBeenCalled();
expect(result).toEqual(Either.right({ match: false }));
});
it('calls diffMappings() with the source and target mappings', async () => {
const task = checkTargetMappings({
sourceIndexMappings,
targetIndexMappings,
});
await task();
expect(diffMappings).toHaveBeenCalledTimes(1);
expect(diffMappings).toHaveBeenCalledWith(sourceIndexMappings, targetIndexMappings);
});
it('returns match=true if diffMappings() match', async () => {
diffMappingsMock.mockReturnValueOnce(undefined);
const task = checkTargetMappings({
sourceIndexMappings,
targetIndexMappings,
});
const result = await task();
expect(result).toEqual(Either.right({ match: true }));
});
it('returns match=false if diffMappings() finds differences', async () => {
diffMappingsMock.mockReturnValueOnce({ changedProp: 'field' });
const task = checkTargetMappings({
sourceIndexMappings,
targetIndexMappings,
});
const result = await task();
expect(result).toEqual(Either.right({ match: false }));
});
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as Either from 'fp-ts/lib/Either';
import * as TaskEither from 'fp-ts/lib/TaskEither';
import { IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
import { diffMappings } from '../core/build_active_mappings';
/** @internal */
export interface CheckTargetMappingsParams {
sourceIndexMappings?: IndexMapping;
targetIndexMappings: IndexMapping;
}
/** @internal */
export interface TargetMappingsCompareResult {
match: boolean;
}
export const checkTargetMappings =
({
sourceIndexMappings,
targetIndexMappings,
}: CheckTargetMappingsParams): TaskEither.TaskEither<never, TargetMappingsCompareResult> =>
async () => {
if (!sourceIndexMappings) {
return Either.right({ match: false });
}
const diff = diffMappings(sourceIndexMappings, targetIndexMappings);
return Either.right({ match: !diff });
};

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { RetryableEsClientError } from './catch_retryable_es_client_errors';
import { DocumentsTransformFailed } from '../core/migrate_raw_docs';
import { type Either, right } from 'fp-ts/lib/Either';
import type { RetryableEsClientError } from './catch_retryable_es_client_errors';
import type { DocumentsTransformFailed } from '../core/migrate_raw_docs';
export {
BATCH_SIZE,
@ -78,6 +79,12 @@ export { updateAliases } from './update_aliases';
export type { CreateIndexParams } from './create_index';
export { createIndex } from './create_index';
export { checkTargetMappings } from './check_target_mappings';
export { updateTargetMappingsMeta } from './update_target_mappings_meta';
export const noop = async (): Promise<Either<never, 'noop'>> => right('noop' as const);
export type {
UpdateAndPickupMappingsResponse,
UpdateAndPickupMappingsParams,

View file

@ -10,6 +10,7 @@ import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors
import { errors as EsErrors } from '@elastic/elasticsearch';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { updateAndPickupMappings } from './update_and_pickup_mappings';
import { DEFAULT_TIMEOUT } from './constants';
jest.mock('./catch_retryable_es_client_errors');
@ -29,6 +30,7 @@ describe('updateAndPickupMappings', () => {
const client = elasticsearchClientMock.createInternalClient(
elasticsearchClientMock.createErrorTransportRequestPromise(retryableError)
);
it('calls catchRetryableEsClientErrors when the promise rejects', async () => {
const task = updateAndPickupMappings({
client,
@ -43,4 +45,43 @@ describe('updateAndPickupMappings', () => {
expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError);
});
it('updates the _mapping properties but not the _meta information', async () => {
const task = updateAndPickupMappings({
client,
index: 'new_index',
mappings: {
properties: {
'apm-indices': {
type: 'object',
dynamic: false,
},
},
_meta: {
migrationMappingPropertyHashes: {
references: '7997cf5a56cc02bdc9c93361bde732b0',
'epm-packages': '860e23f4404fa1c33f430e6dad5d8fa2',
'cases-connector-mappings': '17d2e9e0e170a21a471285a5d845353c',
},
},
},
});
try {
await task();
} catch (e) {
/** ignore */
}
expect(client.indices.putMapping).toHaveBeenCalledTimes(1);
expect(client.indices.putMapping).toHaveBeenCalledWith({
index: 'new_index',
timeout: DEFAULT_TIMEOUT,
properties: {
'apm-indices': {
type: 'object',
dynamic: false,
},
},
});
});
});

View file

@ -45,11 +45,13 @@ export const updateAndPickupMappings = ({
RetryableEsClientError,
'update_mappings_succeeded'
> = () => {
// ._meta property will be updated on a later step
const { _meta, ...mappingsWithoutMeta } = mappings;
return client.indices
.putMapping({
index,
timeout: DEFAULT_TIMEOUT,
...mappings,
...mappingsWithoutMeta,
})
.then(() => {
// Ignore `acknowledged: false`. When the coordinating node accepts

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors';
import { errors as EsErrors } from '@elastic/elasticsearch';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { updateTargetMappingsMeta } from './update_target_mappings_meta';
import { DEFAULT_TIMEOUT } from './constants';
jest.mock('./catch_retryable_es_client_errors');
describe('updateTargetMappingsMeta', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// Create a mock client that rejects all methods with a 503 status code
// response.
const retryableError = new EsErrors.ResponseError(
elasticsearchClientMock.createApiResponse({
statusCode: 503,
body: { error: { type: 'es_type', reason: 'es_reason' } },
})
);
const client = elasticsearchClientMock.createInternalClient(
elasticsearchClientMock.createErrorTransportRequestPromise(retryableError)
);
it('calls catchRetryableEsClientErrors when the promise rejects', async () => {
const task = updateTargetMappingsMeta({
client,
index: 'new_index',
meta: {},
});
try {
await task();
} catch (e) {
/** ignore */
}
expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError);
});
it('updates the _meta information of the desired index', async () => {
const task = updateTargetMappingsMeta({
client,
index: 'new_index',
meta: {
migrationMappingPropertyHashes: {
references: '7997cf5a56cc02bdc9c93361bde732b0',
'epm-packages': '860e23f4404fa1c33f430e6dad5d8fa2',
'cases-connector-mappings': '17d2e9e0e170a21a471285a5d845353c',
},
},
});
try {
await task();
} catch (e) {
/** ignore */
}
expect(client.indices.putMapping).toHaveBeenCalledTimes(1);
expect(client.indices.putMapping).toHaveBeenCalledWith({
index: 'new_index',
timeout: DEFAULT_TIMEOUT,
_meta: {
migrationMappingPropertyHashes: {
references: '7997cf5a56cc02bdc9c93361bde732b0',
'epm-packages': '860e23f4404fa1c33f430e6dad5d8fa2',
'cases-connector-mappings': '17d2e9e0e170a21a471285a5d845353c',
},
},
});
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as Either from 'fp-ts/lib/Either';
import * as TaskEither from 'fp-ts/lib/TaskEither';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { IndexMappingMeta } from '@kbn/core-saved-objects-base-server-internal';
import {
catchRetryableEsClientErrors,
RetryableEsClientError,
} from './catch_retryable_es_client_errors';
import { DEFAULT_TIMEOUT } from './constants';
/** @internal */
export interface UpdateTargetMappingsMetaParams {
client: ElasticsearchClient;
index: string;
meta?: IndexMappingMeta;
}
/**
* Updates an index's mappings _meta information
*/
export const updateTargetMappingsMeta =
({
client,
index,
meta,
}: UpdateTargetMappingsMetaParams): TaskEither.TaskEither<
RetryableEsClientError,
'update_mappings_meta_succeeded'
> =>
() => {
return client.indices
.putMapping({
index,
timeout: DEFAULT_TIMEOUT,
_meta: meta || {},
})
.then(() => {
// Ignore `acknowledged: false`. When the coordinating node accepts
// the new cluster state update but not all nodes have applied the
// update within the timeout `acknowledged` will be false. However,
// retrying this update will always immediately result in `acknowledged:
// true` even if there are still nodes which are falling behind with
// cluster state updates.
return Either.right('update_mappings_meta_succeeded' as const);
})
.catch(catchRetryableEsClientErrors);
};

View file

@ -41,6 +41,10 @@ import type {
ReindexSourceToTempIndexBulk,
CheckUnknownDocumentsState,
CalculateExcludeFiltersState,
PostInitState,
CheckVersionIndexReadyActions,
UpdateTargetMappingsMeta,
CheckTargetMappingsState,
} from '../state';
import { TransformErrorObjects, TransformSavedObjectDocumentError } from '../core';
import { AliasAction, RetryableEsClientError } from '../actions';
@ -2041,15 +2045,37 @@ describe('migrations v2 model', () => {
hasTransformedDocs: false,
};
it('OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT -> UPDATE_TARGET_MAPPINGS if action succeeded', () => {
it('OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT -> CHECK_TARGET_MAPPINGS if action succeeded', () => {
const res: ResponseType<'OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT'> = Either.right({});
const newState = model(state, res) as UpdateTargetMappingsState;
expect(newState.controlState).toBe('UPDATE_TARGET_MAPPINGS');
const newState = model(state, res) as CheckTargetMappingsState;
expect(newState.controlState).toBe('CHECK_TARGET_MAPPINGS');
// @ts-expect-error pitId shouldn't leak outside
expect(newState.pitId).toBe(undefined);
});
});
describe('CHECK_TARGET_MAPPINGS', () => {
const checkTargetMappingsState: CheckTargetMappingsState = {
...baseState,
controlState: 'CHECK_TARGET_MAPPINGS',
versionIndexReadyActions: Option.none,
sourceIndex: Option.some('.kibana') as Option.Some<string>,
targetIndex: '.kibana_7.11.0_001',
};
it('CHECK_TARGET_MAPPINGS -> UPDATE_TARGET_MAPPINGS if mappings do not match', () => {
const res: ResponseType<'CHECK_TARGET_MAPPINGS'> = Either.right({ match: false });
const newState = model(checkTargetMappingsState, res) as UpdateTargetMappingsState;
expect(newState.controlState).toBe('UPDATE_TARGET_MAPPINGS');
});
it('CHECK_TARGET_MAPPINGS -> CHECK_VERSION_INDEX_READY_ACTIONS if mappings match', () => {
const res: ResponseType<'CHECK_TARGET_MAPPINGS'> = Either.right({ match: true });
const newState = model(checkTargetMappingsState, res) as CheckVersionIndexReadyActions;
expect(newState.controlState).toBe('CHECK_VERSION_INDEX_READY_ACTIONS');
});
});
describe('REFRESH_TARGET', () => {
const state: RefreshTarget = {
...baseState,
@ -2310,35 +2336,21 @@ describe('migrations v2 model', () => {
targetIndex: '.kibana_7.11.0_001',
updateTargetMappingsTaskId: 'update target mappings task',
};
test('UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK -> MARK_VERSION_INDEX_READY if some versionIndexReadyActions', () => {
const res: ResponseType<'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK'> = Either.right(
'pickup_updated_mappings_succeeded'
);
const newState = model(
{
...updateTargetMappingsWaitForTaskState,
versionIndexReadyActions: Option.some([
{ add: { index: 'kibana-index', alias: 'my-alias' } },
]),
},
res
) as UpdateTargetMappingsWaitForTaskState;
expect(newState.controlState).toEqual('MARK_VERSION_INDEX_READY');
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
});
test('UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK -> DONE if none versionIndexReadyActions', () => {
test('UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK -> UPDATE_TARGET_MAPPINGS_META if response is right', () => {
const res: ResponseType<'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK'> = Either.right(
'pickup_updated_mappings_succeeded'
);
const newState = model(
updateTargetMappingsWaitForTaskState,
res
) as UpdateTargetMappingsWaitForTaskState;
expect(newState.controlState).toEqual('DONE');
) as UpdateTargetMappingsMeta;
expect(newState.controlState).toEqual('UPDATE_TARGET_MAPPINGS_META');
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
});
test('UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK -> UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK when response is left wait_for_task_completion_timeout', () => {
const res: ResponseType<'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK'> = Either.left({
message: '[timeout_exception] Timeout waiting for ...',
@ -2352,6 +2364,7 @@ describe('migrations v2 model', () => {
expect(newState.retryCount).toEqual(1);
expect(newState.retryDelay).toEqual(2000);
});
test('UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK -> UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK with incremented retry count when response is left wait_for_task_completion_timeout a second time', () => {
const state = Object.assign({}, updateTargetMappingsWaitForTaskState, { retryCount: 1 });
const res: ResponseType<'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK'> = Either.left({
@ -2365,6 +2378,60 @@ describe('migrations v2 model', () => {
});
});
describe('UPDATE_TARGET_MAPPINGS_META', () => {
const updateTargetMappingsMetaState: UpdateTargetMappingsMeta = {
...baseState,
controlState: 'UPDATE_TARGET_MAPPINGS_META',
versionIndexReadyActions: Option.none,
sourceIndex: Option.some('.kibana') as Option.Some<string>,
targetIndex: '.kibana_7.11.0_001',
};
test('UPDATE_TARGET_MAPPINGS_META -> CHECK_VERSION_INDEX_READY_ACTIONS if the mapping _meta information is successfully updated', () => {
const res: ResponseType<'UPDATE_TARGET_MAPPINGS_META'> = Either.right(
'update_mappings_meta_succeeded'
);
const newState = model(updateTargetMappingsMetaState, res) as CheckVersionIndexReadyActions;
expect(newState.controlState).toBe('CHECK_VERSION_INDEX_READY_ACTIONS');
});
});
describe('CHECK_VERSION_INDEX_READY_ACTIONS', () => {
const res: ResponseType<'CHECK_VERSION_INDEX_READY_ACTIONS'> = Either.right('noop');
const postInitState: CheckVersionIndexReadyActions = {
...baseState,
controlState: 'CHECK_VERSION_INDEX_READY_ACTIONS',
versionIndexReadyActions: Option.none,
sourceIndex: Option.some('.kibana') as Option.Some<string>,
targetIndex: '.kibana_7.11.0_001',
};
test('CHECK_VERSION_INDEX_READY_ACTIONS -> MARK_VERSION_INDEX_READY if some versionIndexReadyActions', () => {
const versionIndexReadyActions = Option.some([
{ add: { index: 'kibana-index', alias: 'my-alias' } },
]);
const newState = model(
{
...postInitState,
versionIndexReadyActions,
},
res
) as PostInitState;
expect(newState.controlState).toEqual('MARK_VERSION_INDEX_READY');
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
});
test('CHECK_VERSION_INDEX_READY_ACTIONS -> DONE if none versionIndexReadyActions', () => {
const newState = model(postInitState, res) as PostInitState;
expect(newState.controlState).toEqual('DONE');
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
});
});
describe('CREATE_NEW_TARGET', () => {
const aliasActions = Option.some([Symbol('alias action')] as unknown) as Option.Some<
AliasAction[]

View file

@ -102,6 +102,8 @@ export const model = (currentState: State, resW: ResponseType<AllActionStates>):
// This version's migration has already been completed.
versionMigrationCompleted(stateP.currentAlias, stateP.versionAlias, aliases)
) {
const source = aliases[stateP.currentAlias]!;
return {
...stateP,
// Skip to 'OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT' so that if a new plugin was
@ -112,6 +114,7 @@ export const model = (currentState: State, resW: ResponseType<AllActionStates>):
// index
sourceIndex: Option.none,
targetIndex: `${stateP.indexPrefix}_${stateP.kibanaVersion}_001`,
sourceIndexMappings: indices[source].mappings,
targetIndexMappings: mergeMigrationMappingPropertyHashes(
stateP.targetIndexMappings,
indices[aliases[stateP.currentAlias]!].mappings
@ -1005,7 +1008,7 @@ export const model = (currentState: State, resW: ResponseType<AllActionStates>):
}
return {
...state,
controlState: 'UPDATE_TARGET_MAPPINGS',
controlState: 'CHECK_TARGET_MAPPINGS',
};
} else {
throwBadResponse(stateP, res);
@ -1015,11 +1018,29 @@ export const model = (currentState: State, resW: ResponseType<AllActionStates>):
if (Either.isRight(res)) {
return {
...stateP,
controlState: 'UPDATE_TARGET_MAPPINGS',
controlState: 'CHECK_TARGET_MAPPINGS',
};
} else {
throwBadResponse(stateP, res);
}
} else if (stateP.controlState === 'CHECK_TARGET_MAPPINGS') {
const res = resW as ResponseType<typeof stateP.controlState>;
if (Either.isRight(res)) {
if (!res.right.match) {
return {
...stateP,
controlState: 'UPDATE_TARGET_MAPPINGS',
};
}
// The md5 of the mappings match, so there's no need to update target mappings
return {
...stateP,
controlState: 'CHECK_VERSION_INDEX_READY_ACTIONS',
};
} else {
throwBadResponse(stateP, res as never);
}
} else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') {
const res = resW as ExcludeRetryableEsError<ResponseType<typeof stateP.controlState>>;
if (Either.isRight(res)) {
@ -1034,25 +1055,10 @@ export const model = (currentState: State, resW: ResponseType<AllActionStates>):
} else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK') {
const res = resW as ExcludeRetryableEsError<ResponseType<typeof stateP.controlState>>;
if (Either.isRight(res)) {
if (Option.isSome(stateP.versionIndexReadyActions)) {
// If there are some versionIndexReadyActions we performed a full
// migration and need to point the aliases to our newly migrated
// index.
return {
...stateP,
controlState: 'MARK_VERSION_INDEX_READY',
versionIndexReadyActions: stateP.versionIndexReadyActions,
};
} else {
// If there are none versionIndexReadyActions another instance
// already completed this migration and we only transformed outdated
// documents and updated the mappings for in case a new plugin was
// enabled.
return {
...stateP,
controlState: 'DONE',
};
}
return {
...stateP,
controlState: 'UPDATE_TARGET_MAPPINGS_META',
};
} else {
const left = res.left;
if (isTypeof(left, 'wait_for_task_completion_timeout')) {
@ -1065,6 +1071,36 @@ export const model = (currentState: State, resW: ResponseType<AllActionStates>):
throwBadResponse(stateP, left);
}
}
} else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS_META') {
const res = resW as ExcludeRetryableEsError<ResponseType<typeof stateP.controlState>>;
if (Either.isRight(res)) {
return {
...stateP,
controlState: 'CHECK_VERSION_INDEX_READY_ACTIONS',
};
} else {
throwBadResponse(stateP, res as never);
}
} else if (stateP.controlState === 'CHECK_VERSION_INDEX_READY_ACTIONS') {
if (Option.isSome(stateP.versionIndexReadyActions)) {
// If there are some versionIndexReadyActions we performed a full
// migration and need to point the aliases to our newly migrated
// index.
return {
...stateP,
controlState: 'MARK_VERSION_INDEX_READY',
versionIndexReadyActions: stateP.versionIndexReadyActions,
};
} else {
// If there are none versionIndexReadyActions another instance
// already completed this migration and we only transformed outdated
// documents and updated the mappings for in case a new plugin was
// enabled.
return {
...stateP,
controlState: 'DONE',
};
}
} else if (stateP.controlState === 'CREATE_NEW_TARGET') {
const res = resW as ExcludeRetryableEsError<ResponseType<typeof stateP.controlState>>;
if (Either.isRight(res)) {

View file

@ -41,6 +41,7 @@ import type {
CheckUnknownDocumentsState,
CalculateExcludeFiltersState,
WaitForMigrationCompletionState,
CheckTargetMappingsState,
} from './state';
import type { TransformRawDocs } from './types';
import * as Actions from './actions';
@ -129,6 +130,11 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra
Actions.cloneIndex({ client, source: state.tempIndex, target: state.targetIndex }),
REFRESH_TARGET: (state: RefreshTarget) =>
Actions.refreshIndex({ client, targetIndex: state.targetIndex }),
CHECK_TARGET_MAPPINGS: (state: CheckTargetMappingsState) =>
Actions.checkTargetMappings({
sourceIndexMappings: state.sourceIndexMappings,
targetIndexMappings: state.targetIndexMappings,
}),
UPDATE_TARGET_MAPPINGS: (state: UpdateTargetMappingsState) =>
Actions.updateAndPickupMappings({
client,
@ -141,6 +147,13 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra
taskId: state.updateTargetMappingsTaskId,
timeout: '60s',
}),
UPDATE_TARGET_MAPPINGS_META: (state: UpdateTargetMappingsState) =>
Actions.updateTargetMappingsMeta({
client,
index: state.targetIndex,
meta: state.targetIndexMappings._meta,
}),
CHECK_VERSION_INDEX_READY_ACTIONS: () => Actions.noop,
OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT: (state: OutdatedDocumentsSearchOpenPit) =>
Actions.openPit({ client, index: state.targetIndex }),
OUTDATED_DOCUMENTS_SEARCH_READ: (state: OutdatedDocumentsSearchRead) =>

View file

@ -286,6 +286,11 @@ export interface RefreshTarget extends PostInitState {
readonly targetIndex: string;
}
export interface CheckTargetMappingsState extends PostInitState {
readonly controlState: 'CHECK_TARGET_MAPPINGS';
readonly sourceIndexMappings?: IndexMapping;
}
export interface UpdateTargetMappingsState extends PostInitState {
/** Update the mappings of the target index */
readonly controlState: 'UPDATE_TARGET_MAPPINGS';
@ -297,9 +302,19 @@ export interface UpdateTargetMappingsWaitForTaskState extends PostInitState {
readonly updateTargetMappingsTaskId: string;
}
export interface UpdateTargetMappingsMeta extends PostInitState {
/** Update the mapping _meta information with the hashes of the mappings for each plugin */
readonly controlState: 'UPDATE_TARGET_MAPPINGS_META';
}
export interface CheckVersionIndexReadyActions extends PostInitState {
readonly controlState: 'CHECK_VERSION_INDEX_READY_ACTIONS';
}
export interface OutdatedDocumentsSearchOpenPit extends PostInitState {
/** Open PiT for target index to search for outdated documents */
readonly controlState: 'OUTDATED_DOCUMENTS_SEARCH_OPEN_PIT';
readonly sourceIndexMappings?: IndexMapping;
}
export interface OutdatedDocumentsSearchRead extends PostInitState {
@ -451,8 +466,11 @@ export type State = Readonly<
| ReindexSourceToTempIndexBulk
| SetTempWriteBlock
| CloneTempToSource
| CheckTargetMappingsState
| UpdateTargetMappingsState
| UpdateTargetMappingsWaitForTaskState
| UpdateTargetMappingsMeta
| CheckVersionIndexReadyActions
| OutdatedDocumentsSearchOpenPit
| OutdatedDocumentsSearchRead
| OutdatedDocumentsSearchClosePit

View file

@ -34,6 +34,7 @@ import {
type UpdateByQueryResponse,
updateAndPickupMappings,
type UpdateAndPickupMappingsResponse,
updateTargetMappingsMeta,
removeWriteBlock,
transformDocs,
waitForIndexStatus,
@ -70,6 +71,11 @@ describe('migration actions', () => {
mappings: {
dynamic: true,
properties: {},
_meta: {
migrationMappingPropertyHashes: {
references: '7997cf5a56cc02bdc9c93361bde732b0',
},
},
},
})();
const sourceDocs = [
@ -147,6 +153,32 @@ describe('migration actions', () => {
})
);
});
it('includes the _meta data of the indices in the response', async () => {
expect.assertions(1);
const res = (await initAction({
client,
indices: ['existing_index_with_docs'],
})()) as Either.Right<unknown>;
expect(res.right).toEqual(
expect.objectContaining({
existing_index_with_docs: {
aliases: {},
mappings: {
// FIXME https://github.com/elastic/elasticsearch-js/issues/1796
dynamic: 'true',
properties: expect.anything(),
_meta: {
migrationMappingPropertyHashes: {
references: '7997cf5a56cc02bdc9c93361bde732b0',
},
},
},
settings: expect.anything(),
},
})
);
});
it('resolves left when cluster.routing.allocation.enabled is incompatible', async () => {
expect.assertions(3);
await client.cluster.putSettings({
@ -1453,6 +1485,47 @@ describe('migration actions', () => {
});
});
describe('updateTargetMappingsMeta', () => {
it('rejects if ES throws an error', async () => {
const task = updateTargetMappingsMeta({
client,
index: 'no_such_index',
meta: {
migrationMappingPropertyHashes: {
references: 'updateda56cc02bdc9c93361bupdated',
newReferences: 'fooBarHashMd509387420934879300d9',
},
},
})();
await expect(task).rejects.toThrow('index_not_found_exception');
});
it('resolves right when mappings._meta are correctly updated', async () => {
const res = await updateTargetMappingsMeta({
client,
index: 'existing_index_with_docs',
meta: {
migrationMappingPropertyHashes: {
newReferences: 'fooBarHashMd509387420934879300d9',
},
},
})();
expect(Either.isRight(res)).toBe(true);
const indices = await client.indices.get({
index: ['existing_index_with_docs'],
});
expect(indices.existing_index_with_docs.mappings?._meta).toEqual({
migrationMappingPropertyHashes: {
newReferences: 'fooBarHashMd509387420934879300d9',
},
});
});
});
describe('updateAliases', () => {
describe('remove', () => {
it('resolves left index_not_found_exception when the index does not exist', async () => {

View file

@ -0,0 +1,190 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import fs from 'fs/promises';
import JSON5 from 'json5';
import { Env } from '@kbn/config';
import { REPO_ROOT } from '@kbn/utils';
import { getEnvOptions } from '@kbn/config-mocks';
import { Root } from '@kbn/core-root-server-internal';
import { LogRecord } from '@kbn/logging';
import {
createRootWithCorePlugins,
createTestServers,
type TestElasticsearchUtils,
} from '../../../../test_helpers/kbn_server';
const logFilePath = Path.join(__dirname, 'check_target_mappings.log');
const delay = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
async function removeLogFile() {
// ignore errors if it doesn't exist
await fs.unlink(logFilePath).catch(() => void 0);
}
async function parseLogFile() {
const logFileContent = await fs.readFile(logFilePath, 'utf-8');
return logFileContent
.split('\n')
.filter(Boolean)
.map((str) => JSON5.parse(str)) as LogRecord[];
}
function logIncludes(logs: LogRecord[], message: string): boolean {
return Boolean(logs?.find((rec) => rec.message.includes(message)));
}
describe('migration v2 - CHECK_TARGET_MAPPINGS', () => {
let esServer: TestElasticsearchUtils;
let root: Root;
let logs: LogRecord[];
beforeEach(async () => await removeLogFile());
afterEach(async () => {
await root?.shutdown();
await esServer?.stop();
await delay(10);
});
it('is not run for new installations', async () => {
const { startES } = createTestServers({
adjustTimeout: (t: number) => jest.setTimeout(t),
settings: {
es: {
license: 'basic',
},
},
});
root = createRoot();
esServer = await startES();
await root.preboot();
await root.setup();
await root.start();
// Check for migration steps present in the logs
logs = await parseLogFile();
expect(logIncludes(logs, 'CREATE_NEW_TARGET')).toEqual(true);
expect(logIncludes(logs, 'CHECK_TARGET_MAPPINGS')).toEqual(false);
});
it('skips UPDATE_TARGET_MAPPINGS for up-to-date deployments, when there are no changes in the mappings', async () => {
const { startES } = createTestServers({
adjustTimeout: (t: number) => jest.setTimeout(t),
settings: {
es: {
license: 'basic',
},
},
});
esServer = await startES();
// start Kibana a first time to create the system indices
root = createRoot();
await root.preboot();
await root.setup();
await root.start();
// stop Kibana and remove logs
await root.shutdown();
await delay(10);
await removeLogFile();
root = createRoot();
await root.preboot();
await root.setup();
await root.start();
// Check for migration steps present in the logs
logs = await parseLogFile();
expect(logIncludes(logs, 'CREATE_NEW_TARGET')).toEqual(false);
expect(logIncludes(logs, 'CHECK_TARGET_MAPPINGS -> CHECK_VERSION_INDEX_READY_ACTIONS')).toEqual(
true
);
expect(logIncludes(logs, 'UPDATE_TARGET_MAPPINGS')).toEqual(false);
expect(logIncludes(logs, 'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK')).toEqual(false);
expect(logIncludes(logs, 'UPDATE_TARGET_MAPPINGS_META')).toEqual(false);
});
it('runs UPDATE_TARGET_MAPPINGS when mappings have changed', async () => {
const currentVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version;
const { startES } = createTestServers({
adjustTimeout: (t: number) => jest.setTimeout(t),
settings: {
es: {
license: 'basic',
dataArchive: Path.join(__dirname, 'archives', '8.4.0_with_sample_data_logs.zip'),
},
},
});
esServer = await startES();
// start Kibana a first time to create the system indices
root = createRoot(currentVersion); // we discard a bunch of SO that have become unknown since 8.4.0
await root.preboot();
await root.setup();
await root.start();
// Check for migration steps present in the logs
logs = await parseLogFile();
expect(logIncludes(logs, 'CREATE_NEW_TARGET')).toEqual(false);
expect(logIncludes(logs, 'CHECK_TARGET_MAPPINGS -> UPDATE_TARGET_MAPPINGS')).toEqual(true);
expect(
logIncludes(logs, 'UPDATE_TARGET_MAPPINGS -> UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK')
).toEqual(true);
expect(
logIncludes(logs, 'UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK -> UPDATE_TARGET_MAPPINGS_META')
).toEqual(true);
expect(
logIncludes(logs, 'UPDATE_TARGET_MAPPINGS_META -> CHECK_VERSION_INDEX_READY_ACTIONS')
).toEqual(true);
expect(
logIncludes(logs, 'CHECK_VERSION_INDEX_READY_ACTIONS -> MARK_VERSION_INDEX_READY')
).toEqual(true);
expect(logIncludes(logs, 'MARK_VERSION_INDEX_READY -> DONE')).toEqual(true);
expect(logIncludes(logs, 'Migration completed')).toEqual(true);
});
});
function createRoot(discardUnknownObjects?: string) {
return createRootWithCorePlugins(
{
migrations: {
discardUnknownObjects,
},
logging: {
appenders: {
file: {
type: 'file',
fileName: logFilePath,
layout: {
type: 'json',
},
},
},
loggers: [
{
name: 'root',
level: 'info',
appenders: ['file'],
},
],
},
},
{
oss: true,
}
);
}