Commit 27704ed5 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 841df318 0aecfcaa
<script>
/*
* The commented part of this component needs to be re-enabled in the refactor process,
* See here for more info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64939
*/
import {
GlBadge,
GlButton,
GlModal,
GlModalDirective,
GlTooltipDirective,
GlEmptyState,
GlTab,
GlTabs,
GlSprintf,
} from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
// import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue';
// import DependencyRow from '~/packages/details/components/dependency_row.vue';
// import InstallationCommands from '~/packages/details/components/installation_commands.vue';
// import PackageFiles from '~/packages/details/components/package_files.vue';
// import PackageHistory from '~/packages/details/components/package_history.vue';
// import PackageListRow from '~/packages/shared/components/package_list_row.vue';
import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
import {
PackageType,
TrackingActions,
SHOW_DELETE_SUCCESS_ALERT,
} from '~/packages/shared/constants';
import { packageTypeToTrackCategory } from '~/packages/shared/utils';
import Tracking from '~/tracking';
export default {
name: 'PackagesApp',
components: {
GlBadge,
GlButton,
GlEmptyState,
GlModal,
GlTab,
GlTabs,
GlSprintf,
PackageTitle: () => import('~/packages/details/components/package_title.vue'),
TerraformTitle: () =>
import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'),
PackagesListLoader,
// PackageListRow,
// DependencyRow,
// PackageHistory,
// AdditionalMetadata,
// InstallationCommands,
// PackageFiles,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin()],
inject: [
'titleComponent',
'projectName',
'canDelete',
'svgPath',
'npmPath',
'npmHelpPath',
'projectListUrl',
'groupListUrl',
],
trackingActions: { ...TrackingActions },
data() {
return {
fileToDelete: null,
packageEntity: {},
};
},
computed: {
packageFiles() {
return this.packageEntity.packageFiles;
},
isLoading() {
return false;
},
isValidPackage() {
return Boolean(this.packageEntity.name);
},
tracking() {
return {
category: packageTypeToTrackCategory(this.packageEntity.package_type),
};
},
hasVersions() {
return this.packageEntity.versions?.length > 0;
},
packageDependencies() {
return this.packageEntity.dependency_links || [];
},
showDependencies() {
return this.packageEntity.package_type === PackageType.NUGET;
},
showFiles() {
return this.packageEntity?.package_type !== PackageType.COMPOSER;
},
},
methods: {
formatSize(size) {
return numberToHumanSize(size);
},
getPackageVersions() {
if (!this.packageEntity.versions) {
// this.fetchPackageVersions();
}
},
async confirmPackageDeletion() {
this.track(TrackingActions.DELETE_PACKAGE);
await this.deletePackage();
const returnTo =
!this.groupListUrl || document.referrer.includes(this.projectName)
? this.projectListUrl
: this.groupListUrl; // to avoid security issue url are supplied from backend
const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
window.location.replace(`${returnTo}?${modalQuery}`);
},
handleFileDelete(file) {
this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE);
this.fileToDelete = { ...file };
this.$refs.deleteFileModal.show();
},
confirmFileDelete() {
this.track(TrackingActions.DELETE_PACKAGE_FILE);
// this.deletePackageFile(this.fileToDelete.id);
this.fileToDelete = null;
},
},
i18n: {
deleteModalTitle: s__(`PackageRegistry|Delete Package Version`),
deleteModalContent: s__(
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`),
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
},
modal: {
packageDeletePrimaryAction: {
text: __('Delete'),
attributes: [
{ variant: 'danger' },
{ category: 'primary' },
{ 'data-qa-selector': 'delete_modal_button' },
],
},
fileDeletePrimaryAction: {
text: __('Delete'),
attributes: [{ variant: 'danger' }, { category: 'primary' }],
},
cancelAction: {
text: __('Cancel'),
},
},
};
</script>
<template>
<gl-empty-state
v-if="!isValidPackage"
:title="s__('PackageRegistry|Unable to load package')"
:description="s__('PackageRegistry|There was a problem fetching the details for this package.')"
:svg-path="svgPath"
/>
<div v-else class="packages-app">
<component :is="titleComponent">
<template #delete-button>
<gl-button
v-if="canDelete"
v-gl-modal="'delete-modal'"
class="js-delete-button"
variant="danger"
category="primary"
data-qa-selector="delete_button"
>
{{ __('Delete') }}
</gl-button>
</template>
</component>
<gl-tabs>
<gl-tab :title="__('Detail')">
<div data-qa-selector="package_information_content">
<!-- <package-history :package-entity="packageEntity" :project-name="projectName" />
<installation-commands
:package-entity="packageEntity"
:npm-path="npmPath"
:npm-help-path="npmHelpPath"
/>
<additional-metadata :package-entity="packageEntity" /> -->
</div>
<!-- <package-files
v-if="showFiles"
:package-files="packageFiles"
:can-delete="canDelete"
@download-file="track($options.trackingActions.PULL_PACKAGE)"
@delete-file="handleFileDelete"
/> -->
</gl-tab>
<gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab">
<template #title>
<span>{{ __('Dependencies') }}</span>
<gl-badge size="sm" data-testid="dependencies-badge">{{
packageDependencies.length
}}</gl-badge>
</template>
<template v-if="packageDependencies.length > 0">
<dependency-row
v-for="(dep, index) in packageDependencies"
:key="index"
:dependency="dep"
/>
</template>
<p v-else class="gl-mt-3" data-testid="no-dependencies-message">
{{ s__('PackageRegistry|This NuGet package has no dependencies.') }}
</p>
</gl-tab>
<gl-tab
:title="__('Other versions')"
title-item-class="js-versions-tab"
@click="getPackageVersions"
>
<template v-if="isLoading && !hasVersions">
<packages-list-loader />
</template>
<template v-else-if="hasVersions">
<!-- <package-list-row
v-for="v in packageEntity.versions"
:key="v.id"
:package-entity="{ name: packageEntity.name, ...v }"
:package-link="v.id.toString()"
:disable-delete="true"
:show-package-type="false"
/> -->
</template>
<p v-else class="gl-mt-3" data-testid="no-versions-message">
{{ s__('PackageRegistry|There are no other versions of this package.') }}
</p>
</gl-tab>
</gl-tabs>
<gl-modal
ref="deleteModal"
class="js-delete-modal"
modal-id="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
@primary="confirmPackageDeletion"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
>
<template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #version>
<strong>{{ packageEntity.version }}</strong>
</template>
<template #name>
<strong>{{ packageEntity.name }}</strong>
</template>
</gl-sprintf>
</gl-modal>
<gl-modal
ref="deleteFileModal"
modal-id="delete-file-modal"
:action-primary="$options.modal.fileDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
@primary="confirmFileDelete"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
>
<template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
<gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent">
<template #filename>
<strong>{{ fileToDelete.file_name }}</strong>
</template>
</gl-sprintf>
</gl-modal>
</div>
</template>
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import PackagesApp from '../components/details/app.vue';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-detail-new');
if (!el) {
return null;
}
const { canDelete, ...datasetOptions } = el.dataset;
return new Vue({
el,
provide: {
canDelete: parseBoolean(canDelete),
titleComponent: 'PackageTitle',
...datasetOptions,
},
render(createElement) {
return createElement(PackagesApp);
},
});
};
import initPackageDetail from '~/packages/details/';
initPackageDetail();
(async function initPackage() {
let app;
if (document.getElementById('js-vue-packages-detail-new')) {
app = await import(
/* webpackChunkName: 'new_package_app' */ `~/packages_and_registries/package_registry/pages/details.js`
);
} else {
app = await import('~/packages/details/');
}
app.default();
})();
......@@ -63,4 +63,29 @@ module PackagesHelper
project.container_expiration_policy.nil? &&
project.container_repositories.exists?
end
def package_details_data(project, package = nil)
{
package: package ? package_from_presenter(package) : nil,
can_delete: can?(current_user, :destroy_package, project).to_s,
svg_path: image_path('illustrations/no-packages.svg'),
npm_path: package_registry_instance_url(:npm),
npm_help_path: help_page_path('user/packages/npm_registry/index'),
maven_path: package_registry_project_url(project.id, :maven),
maven_help_path: help_page_path('user/packages/maven_repository/index'),
conan_path: package_registry_project_url(project.id, :conan),
conan_help_path: help_page_path('user/packages/conan_repository/index'),
nuget_path: nuget_package_registry_url(project.id),
nuget_help_path: help_page_path('user/packages/nuget_repository/index'),
pypi_path: pypi_registry_url(project.id),
pypi_setup_path: package_registry_project_url(project.id, :pypi),
pypi_help_path: help_page_path('user/packages/pypi_repository/index'),
composer_path: composer_registry_url(project&.group&.id),
composer_help_path: help_page_path('user/packages/composer_repository/index'),
project_name: project.name,
project_list_url: project_packages_path(project),
group_list_url: project.group ? group_packages_path(project.group) : '',
composer_config_repository_name: composer_config_repository_name(project.group&.id)
}
end
end
......@@ -40,7 +40,6 @@ class WikiPage
end
validates :title, presence: true
validates :content, presence: true
validate :validate_path_limits, if: :title_changed?
validate :validate_content_size_limit, if: :content_changed?
......
......@@ -6,23 +6,7 @@
.row
.col-12
#js-vue-packages-detail{ data: { package: package_from_presenter(@package),
can_delete: can?(current_user, :destroy_package, @project).to_s,
svg_path: image_path('illustrations/no-packages.svg'),
npm_path: package_registry_instance_url(:npm),
npm_help_path: help_page_path('user/packages/npm_registry/index'),
maven_path: package_registry_project_url(@project.id, :maven),
maven_help_path: help_page_path('user/packages/maven_repository/index'),
conan_path: package_registry_project_url(@project.id, :conan),
conan_help_path: help_page_path('user/packages/conan_repository/index'),
nuget_path: nuget_package_registry_url(@project.id),
nuget_help_path: help_page_path('user/packages/nuget_repository/index'),
pypi_path: pypi_registry_url(@project.id),
pypi_setup_path: package_registry_project_url(@project.id, :pypi),
pypi_help_path: help_page_path('user/packages/pypi_repository/index'),
composer_path: composer_registry_url(@project&.group&.id),
composer_help_path: help_page_path('user/packages/composer_repository/index'),
project_name: @project.name,
project_list_url: project_packages_path(@project),
group_list_url: @project.group ? group_packages_path(@project.group) : '',
composer_config_repository_name: composer_config_repository_name(@project.group&.id)} }
- if Feature.enabled?(:package_details_apollo)
#js-vue-packages-detail-new{ data: package_details_data(@project) }
- else
#js-vue-packages-detail{ data: package_details_data(@project, @package) }
......@@ -84,7 +84,6 @@
- mlops
- mobile_signing_deployment
- navigation
- not_owned
- omnibus_package
- on_call_schedule_management
- onboarding
......
---
name: package_details_apollo
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64939
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334786
milestone: '14.1'
type: development
group: group::package
default_enabled: false
......@@ -226,7 +226,7 @@ Gitaly Cluster consists of multiple components:
- [Load balancer](praefect.md#load-balancer) for distributing requests and providing fault-tolerant access to
Praefect nodes.
- [Praefect](praefect.md#praefect) nodes for managing the cluster and routing requests to Gitaly nodes.
- [PostgreSQL database](praefect.md#postgresql) for persisting cluster metadata and [PgBouncer](praefect.md#pgbouncer),
- [PostgreSQL database](praefect.md#postgresql) for persisting cluster metadata and [PgBouncer](praefect.md#use-pgbouncer),
recommended for pooling Praefect's database connections.
- Gitaly nodes to provide repository storage and Git access.
......
......@@ -43,8 +43,8 @@ default value. The default value depends on the GitLab version.
## Setup Instructions
If you [installed](https://about.gitlab.com/install/) GitLab using the Omnibus
package (highly recommended), follow the steps below:
If you [installed](https://about.gitlab.com/install/) GitLab using the Omnibus GitLab package
(highly recommended), follow the steps below:
1. [Preparation](#preparation)
1. [Configuring the Praefect database](#postgresql)
......@@ -59,25 +59,27 @@ package (highly recommended), follow the steps below:
Before beginning, you should already have a working GitLab instance. [Learn how
to install GitLab](https://about.gitlab.com/install/).
Provision a PostgreSQL server (PostgreSQL 11 or newer).
Provision a PostgreSQL server. We recommend using the PostgreSQL that is shipped
with Omnibus GitLab and use it to configure the PostgreSQL database. You can use an
external PostgreSQL server (version 11 or newer) but you must set it up [manually](#manual-database-setup).
Prepare all your new nodes by [installing
GitLab](https://about.gitlab.com/install/).
Prepare all your new nodes by [installing GitLab](https://about.gitlab.com/install/). You need:
- 1 PostgreSQL node
- 1 PgBouncer node (optional)
- At least 1 Praefect node (minimal storage required)
- 3 Gitaly nodes (high CPU, high memory, fast storage)
- 1 GitLab server
You need the IP/host address for each node.
You also need the IP/host address for each node:
1. `LOAD_BALANCER_SERVER_ADDRESS`: the IP/host address of the load balancer
1. `POSTGRESQL_SERVER_ADDRESS`: the IP/host address of the PostgreSQL server
1. `PRAEFECT_LOADBALANCER_HOST`: the IP/host address of Praefect load balancer
1. `POSTGRESQL_HOST`: the IP/host address of the PostgreSQL server
1. `PGBOUNCER_HOST`: the IP/host address of the PostgreSQL server
1. `PRAEFECT_HOST`: the IP/host address of the Praefect server
1. `GITALY_HOST_*`: the IP or host address of each Gitaly server
1. `GITLAB_HOST`: the IP/host address of the GitLab server
If you are using a cloud provider, you can look up the addresses for each server through your cloud provider's management console.
If you are using Google Cloud Platform, SoftLayer, or any other vendor that provides a virtual private cloud (VPC) you can use the private addresses for each cloud instance (corresponds to "internal address" for Google Cloud Platform) for `PRAEFECT_HOST`, `GITALY_HOST_*`, and `GITLAB_HOST`.
#### Secrets
......@@ -98,6 +100,14 @@ with secure tokens as you complete the setup process.
Praefect cluster directly; that could lead to data loss.
1. `PRAEFECT_SQL_PASSWORD`: this password is used by Praefect to connect to
PostgreSQL.
1. `PRAEFECT_SQL_PASSWORD_HASH`: the hash of password of the Praefect user.
Use `gitlab-ctl pg-password-md5 praefect` to generate the hash. The command
asks for the password for `praefect` user. Enter `PRAEFECT_SQL_PASSWORD`
plaintext password. By default, Praefect uses `praefect` user, but you can
change it.
1. `PGBOUNCER_SQL_PASSWORD_HASH`: the hash of password of the PgBouncer user.
PgBouncer uses this password to connect to PostgreSQL. For more details
see [bundled PgBouncer](../postgresql/pgbouncer.md) documentation.
We note in the instructions below where these secrets are required.
......@@ -108,63 +118,81 @@ Omnibus GitLab installations can use `gitlab-secrets.json` for `GITLAB_SHELL_SEC
NOTE:
Do not store the GitLab application database and the Praefect
database on the same PostgreSQL server if using
[Geo](../geo/index.md). The replication state is internal to each instance
of GitLab and should not be replicated.
database on the same PostgreSQL server if using [Geo](../geo/index.md).
The replication state is internal to each instance of GitLab and should
not be replicated.
These instructions help set up a single PostgreSQL database, which creates a single point of
failure. The following options are available:
failure. Alternatively, [you can use PostgreSQL replication and failover](../postgresql/replication_and_failover.md).
The following options are available:
- For non-Geo installations, either:
- Use one of the documented [PostgreSQL setups](../postgresql/index.md).
- Use your own third-party database setup, if fault tolerance is required.
- Use your own third-party database setup. This will require [manual setup](#manual-database-setup).
- For Geo instances, either:
- Set up a separate [PostgreSQL instance](https://www.postgresql.org/docs/11/high-availability.html).
- Use a cloud-managed PostgreSQL service. AWS
[Relational Database Service](https://aws.amazon.com/rds/) is recommended.
#### Manual database setup
To complete this section you need:
- 1 Praefect node
- 1 PostgreSQL server (PostgreSQL 11 or newer)
- An SQL user with permissions to create databases
- One Praefect node
- One PostgreSQL node (version 11 or newer)
- A PostgreSQL user with permissions to manage the database server
During this section, we configure the PostgreSQL server, from the Praefect
node, using `psql` which is installed by Omnibus GitLab.
In this section, we configure the PostgreSQL database. This can be used for both external
and Omnibus-provided PostgreSQL server.
1. SSH into the **Praefect** node and login as root:
To run the following instructions, you can use the Praefect node, where `psql` is installed
by Omnibus GitLab (`/opt/gitlab/embedded/bin/psql`). If you are using the Omnibus-provided
PostgreSQL you can use `gitlab-psql` on the PostgreSQL node instead:
```shell
sudo -i
```
1. Connect to the PostgreSQL server with administrative access. This is likely
the `postgres` user. The database `template1` is used because it is created
by default on all PostgreSQL servers.
1. Create a new user `praefect` to be used by Praefect:
```shell
/opt/gitlab/embedded/bin/psql -U postgres -d template1 -h POSTGRESQL_SERVER_ADDRESS
```sql
CREATE ROLE praefect WITH LOGIN PASSWORD 'PRAEFECT_SQL_PASSWORD';
```
Create a new user `praefect` to be used by Praefect. Replace
`PRAEFECT_SQL_PASSWORD` with the strong password you generated in the
preparation step.
Replace `PRAEFECT_SQL_PASSWORD` with the strong password you generated in the preparation step.
1. Create a new database `praefect_production` that is owned by `praefect` user.
```sql
CREATE ROLE praefect WITH LOGIN CREATEDB PASSWORD 'PRAEFECT_SQL_PASSWORD';
CREATE DATABASE praefect_production WITH OWNER praefect ENCODING UTF8;
```
1. Reconnect to the PostgreSQL server, this time as the `praefect` user:
For using Omnibus-provided PgBouncer you need to take the following additional steps. We strongly
recommend using the PostgreSQL that is shipped with Omnibus as the backend. The following
instructions only work on Omnibus-provided PostgreSQL:
```shell
/opt/gitlab/embedded/bin/psql -U praefect -d template1 -h POSTGRESQL_SERVER_ADDRESS
1. For Omnibus-provided PgBouncer, you need to use the hash of `praefect` user instead the of the
actual password:
```sql
ALTER ROLE praefect WITH PASSWORD 'md5<PRAEFECT_SQL_PASSWORD_HASH>';
```
Create a new database `praefect_production`. By creating the database while
connected as the `praefect` user, we are confident they have access.
Replace `<PRAEFECT_SQL_PASSWORD_HASH>` with the hash of the password you generated in the
preparation step. Note that it is prefixed with `md5` literal.
1. The PgBouncer that is shipped with Omnibus is configured to use [`auth_query`](https://www.pgbouncer.org/config.html#generic-settings)
and uses `pg_shadow_lookup` function. You need to create this function in `praefect_production`
database:
```sql
CREATE DATABASE praefect_production WITH ENCODING=UTF8;
CREATE OR REPLACE FUNCTION public.pg_shadow_lookup(in i_username text, out username text, out password text) RETURNS record AS $$
BEGIN
SELECT usename, passwd FROM pg_catalog.pg_shadow
WHERE usename = i_username INTO username, password;
RETURN;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
REVOKE ALL ON FUNCTION public.pg_shadow_lookup(text) FROM public, pgbouncer;
GRANT EXECUTE ON FUNCTION public.pg_shadow_lookup(text) TO pgbouncer;
```
The database used by Praefect is now configured.
......@@ -172,63 +200,128 @@ The database used by Praefect is now configured.
If you see Praefect database errors after configuring PostgreSQL, see
[troubleshooting steps](troubleshooting.md#relation-does-not-exist-errors).
#### PgBouncer
#### Use PgBouncer
To reduce PostgreSQL resource consumption, we recommend setting up and configuring
[PgBouncer](https://www.pgbouncer.org/) in front of the PostgreSQL instance. To do
this, set the corresponding IP or host address of the PgBouncer instance in
`/etc/gitlab/gitlab.rb` by changing the following settings:
this, you must point Praefect to PgBouncer by setting Praefect database parameters:
- `praefect['database_host']`, for the address.
- `praefect['database_port']`, for the port.
```ruby
praefect['database_host'] = PGBOUNCER_HOST
praefect['database_port'] = 6432
praefect['database_user'] = 'praefect'
praefect['database_password'] = PRAEFECT_SQL_PASSWORD
praefect['database_dbname'] = 'praefect_production'
#praefect['database_sslmode'] = '...'
#praefect['database_sslcert'] = '...'
#praefect['database_sslkey'] = '...'
#praefect['database_sslrootcert'] = '...'
```
Because PgBouncer manages resources more efficiently, Praefect still requires a
direct connection to the PostgreSQL database. It uses the
[LISTEN](https://www.postgresql.org/docs/11/sql-listen.html)
feature that is [not supported](https://www.pgbouncer.org/features.html) by
PgBouncer with `pool_mode = transaction`.
Set `praefect['database_host_no_proxy']` and `praefect['database_port_no_proxy']`
to a direct connection, and not a PgBouncer connection.
Praefect requires an additional connection to the PostgreSQL that supports the
[LISTEN](https://www.postgresql.org/docs/11/sql-listen.html) feature. With PgBouncer
this feature is only available with `session` pool mode (`pool_mode = session`).
It is not supported in `transaction` pool mode (`pool_mode = transaction`).
Save the changes to `/etc/gitlab/gitlab.rb` and
[reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure).
For the additional connection, you must either:
This documentation doesn't provide PgBouncer installation instructions,
but you can:
- Connect Praefect directly to PostgreSQL and bypass PgBouncer.
- Configure a new PgBouncer database that uses to the same PostgreSQL database endpoint,
but with different pool mode. That is, `pool_mode = session`.
- Find instructions on the [official website](https://www.pgbouncer.org/install.html).
- Use a [Docker image](https://hub.docker.com/r/edoburu/pgbouncer/).
Praefect can be configured to use different connection parameters for direct access
to PostgreSQL. This is the connection that supports the `LISTEN` feature.
In addition to the base PgBouncer configuration options, set the following values in
your `pgbouncer.ini` file:
Here is an example of Praefect that bypasses PgBouncer and directly connects to PostgreSQL:
- The [Praefect PostgreSQL database](#postgresql) in the `[databases]` section:
```ruby
praefect['database_direct_host'] = POSTGRESQL_HOST
praefect['database_direct_port'] = 5432
# Use the following to override parameters of direct database connection.
# Comment out where the parameters are the same for both connections.
praefect['database_direct_user'] = 'praefect'
praefect['database_direct_password'] = PRAEFECT_SQL_PASSWORD
praefect['database_direct_dbname'] = 'praefect_production'
#praefect['database_direct_sslmode'] = '...'
#praefect['database_direct_sslcert'] = '...'
#praefect['database_direct_sslkey'] = '...'
#praefect['database_direct_sslrootcert'] = '...'
```
```ini
[databases]
* = host=POSTGRESQL_SERVER_ADDRESS port=5432 auth_user=praefect
```
We recommend using PgBouncer with `session` pool mode instead. You can use the [bundled
PgBouncer](../postgresql/pgbouncer.md) or use an external PgBouncer and [configure it
manually](https://www.pgbouncer.org/config.html).
- [`pool_mode`](https://www.pgbouncer.org/config.html#pool_mode)
and [`ignore_startup_parameters`](https://www.pgbouncer.org/config.html#ignore_startup_parameters)
in the `[pgbouncer]` section:
The following example uses the bundled PgBouncer and sets up two separate connection pools,
one in `session` pool mode and the other in `transaction` pool mode. For this example to work,
you need to prepare PostgreSQL server with [setup instruction](#manual-database-setup):
```ini
[pgbouncer]
pool_mode = transaction
ignore_startup_parameters = extra_float_digits
```
```ruby
pgbouncer['databases'] = {
# Other database configuation including gitlabhq_production
...
praefect_production: {
host: POSTGRESQL_HOST,
# Use `pgbouncer` user to connect to database backend.
user: 'pgbouncer',
password: PGBOUNCER_SQL_PASSWORD_HASH,
pool_mode: 'transaction'
}
praefect_production_direct: {
host: POSTGRESQL_HOST,
# Use `pgbouncer` user to connect to database backend.
user: 'pgbouncer',
password: PGBOUNCER_SQL_PASSWORD_HASH,
dbname: 'praefect_production',
pool_mode: 'session'
},
...
}
```
Both `praefect_production` and `praefect_production_direct` use the same database endpoint
(`praefect_production`), but with different pool modes. This translates to the following
`databases` section of PgBouncer:
```ini
[databases]
praefect_production = host=POSTGRESQL_HOST auth_user=pgbouncer pool_mode=transaction
praefect_production_direct = host=POSTGRESQL_HOST auth_user=pgbouncer dbname=praefect_production pool_mode=session
```
Now you can configure Praefect to use PgBouncer for both connections:
```ruby
praefect['database_host'] = PGBOUNCER_HOST
praefect['database_port'] = 6432
praefect['database_user'] = 'praefect'
# `PRAEFECT_SQL_PASSWORD` is the plain-text password of
# Praefect user. Not to be confused with `PRAEFECT_SQL_PASSWORD_HASH`.
praefect['database_password'] = PRAEFECT_SQL_PASSWORD
praefect['database_dbname'] = 'praefect_production'
praefect['database_direct_dbname'] = 'praefect_production_direct'
# There is no need to repeat the following. Parameters of direct
# database connection will fall back to the values above.
#praefect['database_direct_host'] = PGBOUNCER_HOST
#praefect['database_direct_port'] = 6432
#praefect['database_direct_user'] = 'praefect'
#praefect['database_direct_password'] = PRAEFECT_SQL_PASSWORD
```
The `praefect` user and its password should be included in the file (default is
`userlist.txt`) used by PgBouncer if the [`auth_file`](https://www.pgbouncer.org/config.html#auth_file)
configuration option is set.
With this configuration, Praefect uses PgBouncer for both connection types.
NOTE:
By default PgBouncer uses port `6432` to accept incoming
connections. You can change it by setting the [`listen_port`](https://www.pgbouncer.org/config.html#listen_port)
configuration option. We recommend setting it to the default port value (`5432`) used by
PostgreSQL instances. Otherwise you should change the configuration parameter
`praefect['database_port']` for each Praefect instance to the correct value.
Omnibus GitLab handles the authentication requirements (using `auth_query`), but if you are preparing
your databases manually and configuring an external PgBouncer, you must include `praefect` user and
its password in the file used by PgBouncer. For example, `userlist.txt` if the [`auth_file`](https://www.pgbouncer.org/config.html#auth_file)
configuration option is set. For more details, consult the PgBouncer documentation.
### Praefect
......@@ -241,17 +334,10 @@ If there are multiple Praefect nodes:
To complete this section you need a [configured PostgreSQL server](#postgresql), including:
- IP/host address (`POSTGRESQL_SERVER_ADDRESS`)
- Password (`PRAEFECT_SQL_PASSWORD`)
Praefect should be run on a dedicated node. Do not run Praefect on the
application server, or a Gitaly node.
1. SSH into the **Praefect** node and login as root:
```shell
sudo -i
```
On the **Praefect** node:
1. Disable all other services by editing `/etc/gitlab/gitlab.rb`:
......@@ -295,22 +381,8 @@ application server, or a Gitaly node.
praefect['auth_token'] = 'PRAEFECT_EXTERNAL_TOKEN'
```
1. Configure **Praefect** to connect to the PostgreSQL database by editing
`/etc/gitlab/gitlab.rb`.
You need to replace `POSTGRESQL_SERVER_ADDRESS` with the IP/host address
of the database, and `PRAEFECT_SQL_PASSWORD` with the strong password set
above.
```ruby
praefect['database_host'] = 'POSTGRESQL_SERVER_ADDRESS'
praefect['database_port'] = 5432
praefect['database_user'] = 'praefect'
praefect['database_password'] = 'PRAEFECT_SQL_PASSWORD'
praefect['database_dbname'] = 'praefect_production'
praefect['database_host_no_proxy'] = 'POSTGRESQL_SERVER_ADDRESS'
praefect['database_port_no_proxy'] = 5432
```
1. Configure **Praefect** to [connect to the PostgreSQL database](#postgresql). We
highly recommend using [PgBouncer](#use-pgbouncer) as well.
If you want to use a TLS client certificate, the options below can be used:
......@@ -507,7 +579,7 @@ To configure Praefect with TLS:
```ruby
git_data_dirs({
"default" => {
"gitaly_address" => 'tls://LOAD_BALANCER_SERVER_ADDRESS:2305',
"gitaly_address" => 'tls://PRAEFECT_LOADBALANCER_HOST:2305',
"gitaly_token" => 'PRAEFECT_EXTERNAL_TOKEN'
}
})
......@@ -544,7 +616,7 @@ To configure Praefect with TLS:
repositories:
storages:
default:
gitaly_address: tls://LOAD_BALANCER_SERVER_ADDRESS:3305
gitaly_address: tls://PRAEFECT_LOADBALANCER_HOST:3305
path: /some/local/path
```
......@@ -817,7 +889,7 @@ Particular attention should be shown to:
You need to replace:
- `LOAD_BALANCER_SERVER_ADDRESS` with the IP address or hostname of the load
- `PRAEFECT_LOADBALANCER_HOST` with the IP address or hostname of the load
balancer.
- `PRAEFECT_EXTERNAL_TOKEN` with the real secret
......@@ -826,7 +898,7 @@ Particular attention should be shown to:
```ruby
git_data_dirs({
"default" => {
"gitaly_address" => "tcp://LOAD_BALANCER_SERVER_ADDRESS:2305",
"gitaly_address" => "tcp://PRAEFECT_LOADBALANCER_HOST:2305",
"gitaly_token" => 'PRAEFECT_EXTERNAL_TOKEN'
}
})
......@@ -926,7 +998,7 @@ For example:
git_data_dirs({
'default' => { 'gitaly_address' => 'tcp://old-gitaly.internal:8075' },
'cluster' => {
'gitaly_address' => 'tcp://<load_balancer_server_address>:2305',
'gitaly_address' => 'tcp://<PRAEFECT_LOADBALANCER_HOST>:2305',
'gitaly_token' => '<praefect_external_token>'
}
})
......
......@@ -52,6 +52,20 @@ This content has been moved to a [new location](replication_and_failover.md#conf
}
```
You can pass additional configuration parameters per database, for example:
```ruby
pgbouncer['databases'] = {
gitlabhq_production: {
...
pool_mode: 'transaction'
}
}
```
Use these parameters with caution. For the complete list of parameters refer to the
[PgBouncer documentation](https://www.pgbouncer.org/config.html#section-databases).
1. Run `gitlab-ctl reconfigure`
1. On the node running Puma, make sure the following is set in `/etc/gitlab/gitlab.rb`
......
......@@ -415,6 +415,10 @@ which is used to track vulnerabilities
as new commits are pushed to the repository.
The attributes used to generate the location fingerprint also depend on the type of scanning.
### Details
The `details` field is an object that supports many different content elements that are displayed when viewing vulnerability information. An example of the various data elements can be seen in the [security-reports repository](https://gitlab.com/gitlab-examples/security/security-reports/-/tree/master/samples/details-example).
#### Dependency Scanning
The `location` of a Dependency Scanning vulnerability is composed of a `dependency` and a `file`.
......
......@@ -4,7 +4,7 @@ group: Database
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Iterating Tables In Batches
# Iterating tables in batches
Rails provides a method called `in_batches` that can be used to iterate over
rows in batches. For example:
......@@ -15,7 +15,7 @@ User.in_batches(of: 10) do |relation|
end
```
Unfortunately this method is implemented in a way that is not very efficient,
Unfortunately, this method is implemented in a way that is not very efficient,
both query and memory usage wise.
To work around this you can include the `EachBatch` module into your models,
......@@ -44,17 +44,18 @@ all of the arguments that `in_batches` supports. You should always use
## Avoid iterating over non-unique columns
One should proceed with extra caution, and possibly avoid iterating over a column that can contain duplicate values.
When you iterate over an attribute that is not unique, even with the applied max batch size, there is no guarantee that the resulting batches will not surpass it.
The following snippet demonstrates this situation, when one attempt to select `Ci::Build` entries for users with `id` between `1` and `10,s000`, database returns `1 215 178`
matching rows
One should proceed with extra caution, and possibly avoid iterating over a column that can contain
duplicate values. When you iterate over an attribute that is not unique, even with the applied max
batch size, there is no guarantee that the resulting batches will not surpass it. The following
snippet demonstrates this situation when one attempt to select `Ci::Build` entries for users with
`id` between `1` and `10,000`, the database returns `1 215 178` matching rows.
```ruby
[ gstg ] production> Ci::Build.where(user_id: (1..10_000)).size
=> 1215178
```
This happens because built relation is translated into following query
This happens because built relation is translated into the following query
```ruby
[ gstg ] production> puts Ci::Build.where(user_id: (1..10_000)).to_sql
......@@ -62,12 +63,16 @@ SELECT "ci_builds".* FROM "ci_builds" WHERE "ci_builds"."type" = 'Ci::Build' AND
=> nil
```
And queries which filters non-unique column by range `WHERE "ci_builds"."user_id" BETWEEN ? AND ?`, even though the range size is limited to certain threshold (`10,000` in previous example) this threshold does not translates to the size of returned dataset. That happens because when taking `n` possible values of attributes,
one can't tell for sure that the number of records that contains them will be less than `n`.
`And` queries which filter non-unique column by range `WHERE "ci_builds"."user_id" BETWEEN ? AND ?`,
even though the range size is limited to a certain threshold (`10,000` in the previous example) this
threshold does not translate to the size of the returned dataset. That happens because when taking
`n` possible values of attributes, one can't tell for sure that the number of records that contains
them will be less than `n`.
## Column definition
`EachBatch` uses the primary key of the model by default for the iteration. This works most of the cases, however in some cases, you might want to use a different column for the iteration.
`EachBatch` uses the primary key of the model by default for the iteration. This works most of the
cases, however in some cases, you might want to use a different column for the iteration.
```ruby
Project.distinct.each_batch(column: :creator_id, of: 10) do |relation|
......@@ -78,27 +83,38 @@ end
The query above iterates over the project creators and prints them out without duplications.
NOTE:
In case the column is not unique (no unique index definition), calling the `distinct` method on the relation is necessary. Using not unique column without `distinct` may result in `each_batch` falling into endless loop as described at following [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/285097)
In case the column is not unique (no unique index definition), calling the `distinct` method on
the relation is necessary. Using not unique column without `distinct` may result in `each_batch`
falling into an endless loop as described in following
[issue](https://gitlab.com/gitlab-org/gitlab/-/issues/285097).
## `EachBatch` in data migrations
When dealing with data migrations the preferred way to iterate over large volume of data is using `EachBatch`.
When dealing with data migrations the preferred way to iterate over a large volume of data is using
`EachBatch`.
A special case of data migration is a [background migration](background_migrations.md#scheduling)
where the actual data modification is executed in a background job. The migration code that determines
the data ranges (slices) and schedules the background jobs uses `each_batch`.
where the actual data modification is executed in a background job. The migration code that
determines the data ranges (slices) and schedules the background jobs uses `each_batch`.
## Efficient usage of `each_batch`
`EachBatch` helps iterating over large tables. It's important to highlight that `EachBatch` is not going to magically solve all iteration related performance problems and it might not help at all in some scenarios. From the database point of view, correctly configured database indexes are also necessary to make `EachBatch` perform well.
`EachBatch` helps to iterate over large tables. It's important to highlight that `EachBatch` is
not going to magically solve all iteration related performance problems and it might not help at
all in some scenarios. From the database point of view, correctly configured database indexes are
also necessary to make `EachBatch` perform well.
### Example 1: Simple iteration
Let's consider that we want to iterate over the `users` table and print the `User` records to the standard output. The `users` table contains millions of records, thus running one query to fetch the users will likely time out.
Let's consider that we want to iterate over the `users` table and print the `User` records to the
standard output. The `users` table contains millions of records, thus running one query to fetch
the users will likely time out.
![Users table overview](img/each_batch_users_table_v13_7.png)
This is a simplified version of the `users` table which contains several rows. We have a few smaller gaps in the `id` column to make the example a bit more realistic (a few records were already deleted). Currently we have one index on the `id` field.
This is a simplified version of the `users` table which contains several rows. We have a few
smaller gaps in the `id` column to make the example a bit more realistic (a few records were
already deleted). Currently, we have one index on the `id` field.
Loading all users into memory (avoid):
......@@ -117,9 +133,10 @@ User.each_batch(of: 5) do |relation|
end
```
#### How does `each_batch` work?
#### How `each_batch` works
As the first step, it finds the lowest `id` (start `id`) in the table by executing the following database query:
As the first step, it finds the lowest `id` (start `id`) in the table by executing the following
database query:
```sql
SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC LIMIT 1
......@@ -127,9 +144,12 @@ SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC LIMIT 1
![Reading the start id value](img/each_batch_users_table_iteration_1_v13_7.png)
Notice that the query only reads data from the index (`INDEX ONLY SCAN`), the table is not accessed. Database indexes are sorted so taking out the first item is a very cheap operation.
Notice that the query only reads data from the index (`INDEX ONLY SCAN`), the table is not
accessed. Database indexes are sorted so taking out the first item is a very cheap operation.
The next step is to find the next `id` (end `id`) which should respect the batch size configuration. In this example we used batch size of 5. `EachBatch` uses the `OFFSET` clause to get a "shifted" `id` value.
The next step is to find the next `id` (end `id`) which should respect the batch size
configuration. In this example we used a batch size of 5. `EachBatch` uses the `OFFSET` clause
to get a "shifted" `id` value.
```sql
SELECT "users"."id" FROM "users" WHERE "users"."id" >= 1 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5
......@@ -137,19 +157,25 @@ SELECT "users"."id" FROM "users" WHERE "users"."id" >= 1 ORDER BY "users"."id" A
![Reading the end id value](img/each_batch_users_table_iteration_2_v13_7.png)
Again, the query only looks into the index. The `OFFSET 5` takes out the sixth `id` value: this query reads a maximum of six items from the index regardless of the table size or the iteration count.
Again, the query only looks into the index. The `OFFSET 5` takes out the sixth `id` value: this
query reads a maximum of six items from the index regardless of the table size or the iteration
count.
At this point we know the `id` range for the first batch. Now it's time to construct the query for the `relation` block.
At this point, we know the `id` range for the first batch. Now it's time to construct the query
for the `relation` block.
```sql
SELECT "users".* FROM "users" WHERE "users"."id" >= 1 AND "users"."id" < 302
```
![Reading the rows from the users table](img/each_batch_users_table_iteration_3_v13_7.png)
![Reading the rows from the `users` table](img/each_batch_users_table_iteration_3_v13_7.png)
Notice the `<` sign. Previously six items were read from the index and in this query the last value is "excluded". The query will look at the index to get the location of the five `user` rows on the disk and read the rows from the table. The returned array is processed in Ruby.
Notice the `<` sign. Previously six items were read from the index and in this query, the last
value is "excluded". The query will look at the index to get the location of the five `user`
rows on the disk and read the rows from the table. The returned array is processed in Ruby.
The first iteration is done. For the next iteration, the last `id` value is reused from the previous iteration in order to find out the next end `id` value.
The first iteration is done. For the next iteration, the last `id` value is reused from the
previous iteration in order to find out the next end `id` value.
```sql
SELECT "users"."id" FROM "users" WHERE "users"."id" >= 302 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5
......@@ -167,7 +193,8 @@ SELECT "users".* FROM "users" WHERE "users"."id" >= 302 AND "users"."id" < 353
### Example 2: Iteration with filters
Building on top of the previous example, we want to print users with zero sign-in count. We keep track of the number of sign-ins in the `sign_in_count` column so we write the following code:
Building on top of the previous example, we want to print users with zero sign-in count. We keep
track of the number of sign-ins in the `sign_in_count` column so we write the following code:
```ruby
users = User.where(sign_in_count: 0)
......@@ -183,7 +210,10 @@ end
SELECT "users"."id" FROM "users" WHERE "users"."sign_in_count" = 0 ORDER BY "users"."id" ASC LIMIT 1
```
Selecting only the `id` column and ordering by `id` is going to "force" the database to use the index on the `id` (primary key index) column, however we also have an extra condition on the `sign_in_count` column. The column is not part of the index, so the database needs to look into the actual table to find the first matching row.
Selecting only the `id` column and ordering by `id` is going to "force" the database to use the
index on the `id` (primary key index) column however, we also have an extra condition on the
`sign_in_count` column. The column is not part of the index, so the database needs to look into
the actual table to find the first matching row.
![Reading the index with extra filter](img/each_batch_users_table_filter_v13_7.png)
......@@ -193,7 +223,11 @@ The number of scanned rows depends on the data distribution in the table.
- Best case scenario: the first user was never logged in. The database reads only one row.
- Worst case scenario: all users were logged in at least once. The database reads all rows.
In this particular example the database had to read 10 rows (regardless of our batch size setting) to determine the first `id` value. In a "real-world" application it's hard to predict whether the filtering is going to cause problems or not. In case of GitLab, verifying the data on a production replica is a good start, but keep in mind that data distribution on GitLab.com can be different from self-managed instances.
In this particular example, the database had to read 10 rows (regardless of our batch size setting)
to determine the first `id` value. In a "real-world" application it's hard to predict whether the
filtering is going to cause problems or not. In the case of GitLab, verifying the data on a
production replica is a good start, but keep in mind that data distribution on GitLab.com can be
different from self-managed instances.
#### Improve filtering with `each_batch`
......@@ -207,21 +241,26 @@ This is how our table and the newly created index looks like:
![Reading the specialized index](img/each_batch_users_table_filtered_index_v13_7.png)
This index definition covers the conditions on the `id` and `sign_in_count` columns thus makes the `each_batch` queries very effective (similar to the simple iteration example).
This index definition covers the conditions on the `id` and `sign_in_count` columns thus makes the
`each_batch` queries very effective (similar to the simple iteration example).
It's rare when a user was never signed in so we anticipate small index size. Including only the `id` in the index definition also helps keeping the index size small.
It's rare when a user was never signed in so we a anticipate small index size. Including only the
`id` in the index definition also helps to keep the index size small.
##### Index on columns
Later on we might want to iterate over the table filtering for different `sign_in_count` values, in those cases we cannot use the previously suggested conditional index because the `WHERE` condition does not match with our new filter (`sign_in_count > 10`).
Later on, we might want to iterate over the table filtering for different `sign_in_count` values, in
those cases we cannot use the previously suggested conditional index because the `WHERE` condition
does not match with our new filter (`sign_in_count > 10`).
To address this problem, we have two options:
- Create another, conditional index to cover the new query.
- Replace the index with more generalized configuration.
- Replace the index with a more generalized configuration.
NOTE:
Having multiple indexes on the same table and on the same columns could be a performance bottleneck when writing data.
Having multiple indexes on the same table and on the same columns could be a performance bottleneck
when writing data.
Let's consider the following index (avoid):
......@@ -229,15 +268,18 @@ Let's consider the following index (avoid):
CREATE INDEX index_on_users_never_logged_in ON users (id, sign_in_count)
```
The index definition starts with the `id` column which makes the index very inefficient from data selectivity point of view.
The index definition starts with the `id` column which makes the index very inefficient from data
selectivity point of view.
```sql
SELECT "users"."id" FROM "users" WHERE "users"."sign_in_count" = 0 ORDER BY "users"."id" ASC LIMIT 1
```
Executing the query above results in an `INDEX ONLY SCAN`. However, the query still needs to iterate over unknown number of entries in the index, and then find the first item where the `sign_in_count` is `0`.
Executing the query above results in an `INDEX ONLY SCAN`. However, the query still needs to
iterate over an unknown number of entries in the index, and then find the first item where the
`sign_in_count` is `0`.
![Reading the an ineffective index](img/each_batch_users_table_bad_index_v13_7.png)
![Reading an ineffective index](img/each_batch_users_table_bad_index_v13_7.png)
We can improve the query significantly by swapping the columns in the index definition (prefer).
......@@ -253,11 +295,14 @@ The following index definition is not going to work well with `each_batch` (avoi
CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count)
```
Since `each_batch` builds range queries based on the `id` column, this index cannot be used efficiently. The DB reads the rows from the table or uses a bitmap search where the primary key index is also read.
Since `each_batch` builds range queries based on the `id` column, this index cannot be used
efficiently. The DB reads the rows from the table or uses a bitmap search where the primary
key index is also read.
##### "Slow" iteration
Slow iteration means that we use a good index configuration to iterate over the table and apply filtering on the yielded relation.
Slow iteration means that we use a good index configuration to iterate over the table and
apply filtering on the yielded relation.
```ruby
User.each_batch(of: 5) do |relation|
......@@ -266,7 +311,8 @@ end
```
The iteration uses the primary key index (on the `id` column) which makes it safe from statement
timeouts. The filter (`sign_in_count: 0`) is applied on the `relation` where the `id` is already constrained (range). The number of rows are limited.
timeouts. The filter (`sign_in_count: 0`) is applied on the `relation` where the `id` is already
constrained (range). The number of rows is limited.
Slow iteration generally takes more time to finish. The iteration count is higher and
one iteration could yield fewer records than the batch size. Iterations may even yield
......@@ -285,18 +331,19 @@ projects.each_batch do |relation|
end
```
The iteration uses the `id` column of the `projects` table. The batching does not affect the subquery.
This means for each iteration, the subquery is executed by the database. This adds a constant "load"
on the query which often ends up in statement timeouts. We have an unknown number of confidential
issues, the execution time and the accessed database rows depends on the data distribution in the
`issues` table.
The iteration uses the `id` column of the `projects` table. The batching does not affect the
subquery. This means for each iteration, the subquery is executed by the database. This adds a
constant "load" on the query which often ends up in statement timeouts. We have an unknown number
of confidential issues, the execution time and the accessed database rows depend on the data
distribution in the `issues` table.
NOTE:
Using subqueries works only when the subquery returns a small number of rows.
#### Improving Subqueries
When dealing with subqueries, a slow iteration approach could work: the filter on `creator_id` can be part of the generated `relation` object.
When dealing with subqueries, a slow iteration approach could work: the filter on `creator_id`
can be part of the generated `relation` object.
```ruby
projects = Project.all
......@@ -306,7 +353,8 @@ projects.each_batch do |relation|
end
```
If the query on the `issues` table itself is not performant enough, a nested loop could be constructed. Try to avoid it when possible.
If the query on the `issues` table itself is not performant enough, a nested loop could be
constructed. Try to avoid it when possible.
```ruby
projects = Project.all
......@@ -320,7 +368,8 @@ projects.each_batch do |relation|
end
```
If we know that the `issues` table has many more rows than `projects`, it would make sense to flip the queries, where the `issues` table is batched first.
If we know that the `issues` table has many more rows than `projects`, it would make sense to flip
the queries, where the `issues` table is batched first.
### Using `JOIN` and `EXISTS`
......@@ -331,7 +380,8 @@ When to use `JOINS`:
- `projects` - `project_settings`
- `users` - `user_details`
- `users` - `user_statuses`
- `LEFT JOIN` works well in this case. Conditions on the joined table need to go to the yielded relation so the iteration is not affected by the data distribution in the joined table.
- `LEFT JOIN` works well in this case. Conditions on the joined table need to go to the yielded
relation so the iteration is not affected by the data distribution in the joined table.
Example:
......@@ -353,7 +403,8 @@ end
### Complex queries on the relation object
When the `relation` object has several extra conditions, the execution plans might become "unstable".
When the `relation` object has several extra conditions, the execution plans might become
"unstable".
Example:
......@@ -370,10 +421,11 @@ end
Here, we expect that the `relation` query reads the `BATCH_SIZE` of user records and then
filters down the results according to the provided queries. The planner might decide that
using a bitmap index lookup with the index on the `confidential` column is a better way to
execute the query. This can cause unexpectedly high amount of rows to be read and the query
could time out.
execute the query. This can cause an unexpectedly high amount of rows to be read and the
query could time out.
Problem: we know for sure that the relation is returning maximum `BATCH_SIZE` of records, however the planner does not know this.
Problem: we know for sure that the relation is returning maximum `BATCH_SIZE` of records
however, the planner does not know this.
Common table expression (CTE) trick to force the range query to execute first:
......@@ -394,4 +446,132 @@ end
### `EachBatch` vs `BatchCount`
When adding new counters for Service Ping, the preferred way to count records is using the `Gitlab::Database::BatchCount` class. The iteration logic implemented in `BatchCount` has similar performance characteristics like `EachBatch`. Most of the tips and suggestions for improving `BatchCount` mentioned above applies to `BatchCount` as well.
When adding new counters for Service Ping, the preferred way to count records is using the
`Gitlab::Database::BatchCount` class. The iteration logic implemented in `BatchCount`
has similar performance characteristics like `EachBatch`. Most of the tips and suggestions
for improving `BatchCount` mentioned above applies to `BatchCount` as well.
## Iterate with keyset pagination
There are a few special cases where iterating with `EachBatch` does not work. `EachBatch`
requires one distinct column (usually the primary key), which makes the iteration impossible
for timestamp columns and tables with composite primary keys.
Where `EachBatch` does not work, you can use
[keyset pagination](database/pagination_guidelines.md#keyset-pagination) to iterate over the
table or a range of rows. The scaling and performance characteristics are very similar to
`EachBatch`.
Examples:
- Iterate over the table in a specific order (timestamp columns) in combination with a tie-breaker
if column user to sort by does not contain unique values.
- Iterate over the table with composite primary keys.
### Iterate over the issues in a project by creation date
You can use keyset pagination to iterate over any database column in a specific order (for example,
`created_at DESC`). To ensure consistent order of the returned records with the same values for
`created_at`, use a tie-breaker column with unique values (for example, `id`).
Assume you have the following index in the `issues` table:
```sql
idx_issues_on_project_id_and_created_at_and_id" btree (project_id, created_at, id)
```
### Fetching records for further processing
The following snippet iterates over issue records within the project using the specified order
(`created_at, id`).
```ruby
scope = Issue.where(project_id: 278964).order(:created_at, :id) # id is the tie-breaker
iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)
iterator.each_batch(of: 100) do |records|
puts records.map(&:id)
end
```
You can add extra filters to the query. This example only lists the issue IDs created in the last
30 days:
```ruby
scope = Issue.where(project_id: 278964).where('created_at > ?', 30.days.ago).order(:created_at, :id) # id is the tie-breaker
iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)
iterator.each_batch(of: 100) do |records|
puts records.map(&:id)
end
```
### Updating records in the batch
For complex `ActiveRecord` queries, the `.update_all` method does not work well, because it
generates an incorrect `UPDATE` statement.
You can use raw SQL for updating records in batches:
```ruby
scope = Issue.where(project_id: 278964).order(:created_at, :id) # id is the tie-breaker
iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)
iterator.each_batch(of: 100) do |records|
ApplicationRecord.connection.execute("UPDATE issues SET updated_at=NOW() WHERE issues.id in (#{records.dup.reselect(:id).to_sql})")
end
```
NOTE:
To keep the iteration stable and predictable, avoid updating the columns in the `ORDER BY` clause.
### Iterate over the `merge_request_diff_commits` table
The `merge_request_diff_commits` table uses a composite primary key (`merge_request_diff_id,
relative_order`), which makes `EachBatch` impossible to use efficiently.
To paginate over the `merge_request_diff_commits` table, you can use the following snippet:
```ruby
# Custom order object configuration:
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'merge_request_diff_id',
order_expression: MergeRequestDiffCommit.arel_table[:merge_request_diff_id].asc,
nullable: :not_nullable,
distinct: false,
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'relative_order',
order_expression: MergeRequestDiffCommit.arel_table[:relative_order].asc,
nullable: :not_nullable,
distinct: false,
)
])
MergeRequestDiffCommit.include(FromUnion) # keyset pagination generates UNION queries
scope = MergeRequestDiffCommit.order(order)
iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)
iterator.each_batch(of: 100) do |records|
puts records.map { |record| [record.merge_request_diff_id, record.relative_order] }.inspect
end
```
### Order object configuration
Keyset pagination works well with simple `ActiveRecord` `order` scopes
([first example](iterating_tables_in_batches.md#iterate-over-the-issues-in-a-project-by-creation-date).
However, in special cases, you need to describe the columns in the `ORDER BY` clause (second example)
for the underlying keyset pagination library. When the `ORDER BY` configuration cannot be
automatically determined by the keyset pagination library, an error is raised.
The code comments of the
[`Gitlab::Pagination::Keyset::Order`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pagination/keyset/order.rb)
and [`Gitlab::Pagination::Keyset::ColumnOrderDefinition`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pagination/keyset/column_order_definition.rb)
classes give an overview of the possible options for configuring the `ORDER BY` clause. You can
also find a few code examples in the
[keyset pagination](database/keyset_pagination.md#complex-order-configuration) documentation.
......@@ -4,8 +4,12 @@ module Gitlab
module Pagination
module Keyset
class Iterator
def initialize(scope:, use_union_optimization: false)
@scope = scope
UnsupportedScopeOrder = Class.new(StandardError)
def initialize(scope:, use_union_optimization: true)
@scope, success = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build(scope)
raise(UnsupportedScopeOrder, 'The order on the scope does not support keyset pagination') unless success
@order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope)
@use_union_optimization = use_union_optimization
end
......
......@@ -23,7 +23,8 @@ module QA
end
def add_to_file_content(content)
text_area.set content
text_area.click
text_area.send_keys(:home, content) # starts in the beginning of the line
text_area.has_text?(content) # wait for changes to take effect
end
......
......@@ -44,7 +44,13 @@ RSpec.describe 'Group Packages' do
it_behaves_like 'packages list', check_project_name: true
context 'when package_details_apollo feature flag is off' do
before do
stub_feature_flags(package_details_apollo: false)
end
it_behaves_like 'package details link'
end
it 'allows you to navigate to the project page' do
find('[data-testid="root-link"]', text: project.name).click
......
......@@ -23,6 +23,11 @@ RSpec.describe 'PackageFiles' do
expect(status_code).to eq(200)
end
context 'when package_details_apollo feature flag is off' do
before do
stub_feature_flags(package_details_apollo: false)
end
it 'renders the download link with the correct url', :js do
visit project_package_path(project, package)
......@@ -30,6 +35,7 @@ RSpec.describe 'PackageFiles' do
expect(page).to have_link(package_file.file_name, href: download_url)
end
end
it 'does not allow download of package belonging to different project' do
another_package = create(:maven_package)
......
......@@ -37,7 +37,13 @@ RSpec.describe 'Packages' do
it_behaves_like 'packages list'
context 'when package_details_apollo feature flag is off' do
before do
stub_feature_flags(package_details_apollo: false)
end
it_behaves_like 'package details link'
end
context 'deleting a package' do
let_it_be(:project) { create(:project) }
......
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue';
describe('PackagesApp', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(PackagesApp, {
provide: {
titleComponent: 'titleComponent',
projectName: 'projectName',
canDelete: 'canDelete',
svgPath: 'svgPath',
npmPath: 'npmPath',
npmHelpPath: 'npmHelpPath',
projectListUrl: 'projectListUrl',
groupListUrl: 'groupListUrl',
},
});
}
const emptyState = () => wrapper.findComponent(GlEmptyState);
afterEach(() => {
wrapper.destroy();
});
it('renders an empty state component', () => {
createComponent();
expect(emptyState().exists()).toBe(true);
});
});
......@@ -27,27 +27,31 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
order_expression: klass.arel_table[:id].send(direction),
add_to_projections: true
order_expression: klass.arel_table[:id].send(direction)
)
])
end
let(:scope) { project.issues.reorder(custom_reorder) }
subject { described_class.new(scope: scope) }
shared_examples 'iterator examples' do
describe '.each_batch' do
it 'yields an ActiveRecord::Relation when a block is given' do
subject.each_batch(of: 1) do |relation|
iterator.each_batch(of: 1) do |relation|
expect(relation).to be_a_kind_of(ActiveRecord::Relation)
end
end
it 'raises error when ordering configuration cannot be automatically determined' do
expect do
described_class.new(scope: MergeRequestDiffCommit.order(:merge_request_diff_id, :relative_order))
end.to raise_error /The order on the scope does not support keyset pagination/
end
it 'accepts a custom batch size' do
count = 0
subject.each_batch(of: 2) { |relation| count += relation.count(:all) }
iterator.each_batch(of: 2) { |relation| count += relation.count(:all) }
expect(count).to eq(9)
end
......@@ -55,11 +59,11 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
it 'allows updating of the yielded relations' do
time = Time.current
subject.each_batch(of: 2) do |relation|
relation.update_all(updated_at: time)
iterator.each_batch(of: 2) do |relation|
Issue.connection.execute("UPDATE issues SET updated_at = '#{time.to_s(:inspect)}' WHERE id IN (#{relation.reselect(:id).to_sql})")
end
expect(Issue.where(updated_at: time).count).to eq(9)
expect(Issue.pluck(:updated_at)).to all(be_within(5.seconds).of(time))
end
context 'with ordering direction' do
......@@ -67,7 +71,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
it 'orders ascending by default, including secondary order column' do
positions = []
subject.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
expect(positions).to eq(project.issues.order_relative_position_asc.order(id: :asc).pluck(:relative_position, :id))
end
......@@ -79,7 +83,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
it 'orders in reverse of ascending' do
positions = []
subject.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
expect(positions).to eq(project.issues.order_relative_position_desc.order(id: :desc).pluck(:relative_position, :id))
end
......@@ -91,7 +95,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
it 'orders ascending with nulls first' do
positions = []
subject.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id))
end
......@@ -104,7 +108,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
it 'orders descending' do
positions = []
subject.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id))
end
......@@ -117,11 +121,24 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
it 'orders descending' do
positions = []
subject.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:id)) }
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:id)) }
expect(positions).to eq(project.issues.reorder(id: :desc).pluck(:id))
end
end
end
end
end
context 'when use_union_optimization is used' do
subject(:iterator) { described_class.new(scope: scope, use_union_optimization: true) }
include_examples 'iterator examples'
end
context 'when use_union_optimization is not used' do
subject(:iterator) { described_class.new(scope: scope, use_union_optimization: false) }
include_examples 'iterator examples'
end
end
......@@ -201,11 +201,10 @@ RSpec.describe WikiPage do
expect(subject.errors.messages).to eq(title: ["can't be blank"])
end
it "validates presence of content" do
it "does not validate presence of content" do
subject.attributes.delete(:content)
expect(subject).not_to be_valid
expect(subject.errors.messages).to eq(content: ["can't be blank"])
expect(subject).to be_valid
end
describe '#validate_content_size_limit' do
......
......@@ -20,17 +20,6 @@ RSpec.shared_examples 'User creates wiki page' do
click_link "Create your first page"
end
it "shows validation error message if the form is force submitted", :js do
page.within(".wiki-form") do
fill_in(:wiki_content, with: "")
page.execute_script("document.querySelector('.wiki-form').submit()")
page.accept_alert # manually force form submit
end
expect(page).to have_content("The form contains the following error:").and have_content("Content can't be blank")
end
it "disables the submit button", :js do
page.within(".wiki-form") do
fill_in(:wiki_content, with: "")
......
......@@ -90,19 +90,6 @@ RSpec.shared_examples 'User updates wiki page' do
expect(page).to have_field('wiki[message]', with: 'Update Wiki title')
end
it 'shows a validation error message if the form is force submitted', :js do
fill_in(:wiki_content, with: '')
page.execute_script("document.querySelector('.wiki-form').submit()")
page.accept_alert # manually force form submit
expect(page).to have_selector('.wiki-form')
expect(page).to have_content('Edit Page')
expect(page).to have_content('The form contains the following error:')
expect(page).to have_content("Content can't be blank")
expect(find('textarea#wiki_content').value).to eq('')
end
it "disables the submit button", :js do
page.within(".wiki-form") do
fill_in(:wiki_content, with: "")
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment