mirror of
https://github.com/neondatabase/neon.git
synced 2026-05-25 17:10:38 +00:00
Merge branch 'main' into perf-summary
This commit is contained in:
@@ -1,24 +1,39 @@
|
||||
## Zenith test runner
|
||||
## Neon test runner
|
||||
|
||||
This directory contains integration tests.
|
||||
|
||||
Prerequisites:
|
||||
- Correctly configured Python, see [`/docs/sourcetree.md`](/docs/sourcetree.md#using-python)
|
||||
- Zenith and Postgres binaries
|
||||
- Neon and Postgres binaries
|
||||
- See the root [README.md](/README.md) for build directions
|
||||
If you want to test tests with test-only APIs, you would need to add `--features testing` to Rust code build commands.
|
||||
For convenience, repository cargo config contains `build_testing` alias, that serves as a subcommand, adding the required feature flags.
|
||||
Usage example: `cargo build_testing --release` is equivalent to `cargo build --features testing --release`
|
||||
- Tests can be run from the git tree; or see the environment variables
|
||||
below to run from other directories.
|
||||
- The zenith git repo, including the postgres submodule
|
||||
- The neon git repo, including the postgres submodule
|
||||
(for some tests, e.g. `pg_regress`)
|
||||
- Some tests (involving storage nodes coordination) require etcd installed. Follow
|
||||
[`the guide`](https://etcd.io/docs/v3.5/install/) to obtain it.
|
||||
|
||||
### Test Organization
|
||||
|
||||
The tests are divided into a few batches, such that each batch takes roughly
|
||||
the same amount of time. The batches can be run in parallel, to minimize total
|
||||
runtime. Currently, there are only two batches:
|
||||
Regression tests are in the 'regress' directory. They can be run in
|
||||
parallel to minimize total runtime. Most regression test sets up their
|
||||
environment with its own pageservers and safekeepers (but see
|
||||
`TEST_SHARED_FIXTURES`).
|
||||
|
||||
- test_batch_pg_regress: Runs PostgreSQL regression tests
|
||||
- test_others: All other tests
|
||||
'pg_clients' contains tests for connecting with various client
|
||||
libraries. Each client test uses a Dockerfile that pulls an image that
|
||||
contains the client, and connects to PostgreSQL with it. The client
|
||||
tests can be run against an existing PostgreSQL or Neon installation.
|
||||
|
||||
'performance' contains performance regression tests. Each test
|
||||
exercises a particular scenario or workload, and outputs
|
||||
measurements. They should be run serially, to avoid the tests
|
||||
interfering with the performance of each other. Some performance tests
|
||||
set up their own Neon environment, while others can be run against an
|
||||
existing PostgreSQL or Neon environment.
|
||||
|
||||
### Running the tests
|
||||
|
||||
@@ -41,17 +56,30 @@ If you want to run all tests that have the string "bench" in their names:
|
||||
|
||||
`./scripts/pytest -k bench`
|
||||
|
||||
To run tests in parellel we utilize `pytest-xdist` plugin. By default everything runs single threaded. Number of workers can be specified with `-n` argument:
|
||||
|
||||
`./scripts/pytest -n4`
|
||||
|
||||
By default performance tests are excluded. To run them explicitly pass performance tests selection to the script:
|
||||
|
||||
`./scripts/pytest test_runner/performance`
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
`ZENITH_BIN`: The directory where zenith binaries can be found.
|
||||
`NEON_BIN`: The directory where neon binaries can be found.
|
||||
`POSTGRES_DISTRIB_DIR`: The directory where postgres distribution can be found.
|
||||
Since pageserver supports several postgres versions, `POSTGRES_DISTRIB_DIR` must contain
|
||||
a subdirectory for each version with naming convention `v{PG_VERSION}/`.
|
||||
Inside that dir, a `bin/postgres` binary should be present.
|
||||
`DEFAULT_PG_VERSION`: The version of Postgres to use,
|
||||
This is used to construct full path to the postgres binaries.
|
||||
Format is 2-digit major version nubmer, i.e. `DEFAULT_PG_VERSION="14"`
|
||||
`TEST_OUTPUT`: Set the directory where test state and test output files
|
||||
should go.
|
||||
`TEST_SHARED_FIXTURES`: Try to re-use a single pageserver for all the tests.
|
||||
`ZENITH_PAGESERVER_OVERRIDES`: add a `;`-separated set of configs that will be passed as
|
||||
`FORCE_MOCK_S3`: inits every test's pageserver with a mock S3 used as a remote storage.
|
||||
`--pageserver-config-override=${value}` parameter values when zenith cli is invoked
|
||||
`RUST_LOG`: logging configuration to pass into Zenith CLI
|
||||
`NEON_PAGESERVER_OVERRIDES`: add a `;`-separated set of configs that will be passed as
|
||||
`--pageserver-config-override=${value}` parameter values when neon_local cli is invoked
|
||||
`RUST_LOG`: logging configuration to pass into Neon CLI
|
||||
|
||||
Let stdout, stderr and `INFO` log messages go to the terminal instead of capturing them:
|
||||
`./scripts/pytest -s --log-cli-level=INFO ...`
|
||||
@@ -64,32 +92,32 @@ Exit after the first test failure:
|
||||
|
||||
### Writing a test
|
||||
|
||||
Every test needs a Zenith Environment, or ZenithEnv to operate in. A Zenith Environment
|
||||
Every test needs a Neon Environment, or NeonEnv to operate in. A Neon Environment
|
||||
is like a little cloud-in-a-box, and consists of a Pageserver, 0-N Safekeepers, and
|
||||
compute Postgres nodes. The connections between them can be configured to use JWT
|
||||
authentication tokens, and some other configuration options can be tweaked too.
|
||||
|
||||
The easiest way to get access to a Zenith Environment is by using the `zenith_simple_env`
|
||||
The easiest way to get access to a Neon Environment is by using the `neon_simple_env`
|
||||
fixture. The 'simple' env may be shared across multiple tests, so don't shut down the nodes
|
||||
or make other destructive changes in that environment. Also don't assume that
|
||||
there are no tenants or branches or data in the cluster. For convenience, there is a
|
||||
branch called `empty`, though. The convention is to create a test-specific branch of
|
||||
that and load any test data there, instead of the 'main' branch.
|
||||
|
||||
For more complicated cases, you can build a custom Zenith Environment, with the `zenith_env`
|
||||
For more complicated cases, you can build a custom Neon Environment, with the `neon_env`
|
||||
fixture:
|
||||
|
||||
```python
|
||||
def test_foobar(zenith_env_builder: ZenithEnvBuilder):
|
||||
def test_foobar(neon_env_builder: NeonEnvBuilder):
|
||||
# Prescribe the environment.
|
||||
# We want to have 3 safekeeper nodes, and use JWT authentication in the
|
||||
# connections to the page server
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
zenith_env_builder.set_pageserver_auth(True)
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
neon_env_builder.set_pageserver_auth(True)
|
||||
|
||||
# Now create the environment. This initializes the repository, and starts
|
||||
# up the page server and the safekeepers
|
||||
env = zenith_env_builder.init()
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
# Run the test
|
||||
...
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
from contextlib import closing
|
||||
from typing import Iterator
|
||||
from uuid import UUID, uuid4
|
||||
import psycopg2
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder, ZenithPageserverApiException
|
||||
import pytest
|
||||
|
||||
|
||||
def test_pageserver_auth(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.pageserver_auth_enabled = True
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
ps = env.pageserver
|
||||
|
||||
tenant_token = env.auth_keys.generate_tenant_token(env.initial_tenant.hex)
|
||||
tenant_http_client = env.pageserver.http_client(tenant_token)
|
||||
invalid_tenant_token = env.auth_keys.generate_tenant_token(uuid4().hex)
|
||||
invalid_tenant_http_client = env.pageserver.http_client(invalid_tenant_token)
|
||||
|
||||
management_token = env.auth_keys.generate_management_token()
|
||||
management_http_client = env.pageserver.http_client(management_token)
|
||||
|
||||
# this does not invoke auth check and only decodes jwt and checks it for validity
|
||||
# check both tokens
|
||||
ps.safe_psql("set FOO", password=tenant_token)
|
||||
ps.safe_psql("set FOO", password=management_token)
|
||||
|
||||
# tenant can create branches
|
||||
tenant_http_client.branch_create(env.initial_tenant, 'new1', 'main')
|
||||
# console can create branches for tenant
|
||||
management_http_client.branch_create(env.initial_tenant, 'new2', 'main')
|
||||
|
||||
# fail to create branch using token with different tenant_id
|
||||
with pytest.raises(ZenithPageserverApiException,
|
||||
match='Forbidden: Tenant id mismatch. Permission denied'):
|
||||
invalid_tenant_http_client.branch_create(env.initial_tenant, "new3", "main")
|
||||
|
||||
# create tenant using management token
|
||||
management_http_client.tenant_create(uuid4())
|
||||
|
||||
# fail to create tenant using tenant token
|
||||
with pytest.raises(
|
||||
ZenithPageserverApiException,
|
||||
match='Forbidden: Attempt to access management api with tenant scope. Permission denied'
|
||||
):
|
||||
tenant_http_client.tenant_create(uuid4())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('with_wal_acceptors', [False, True])
|
||||
def test_compute_auth_to_pageserver(zenith_env_builder: ZenithEnvBuilder, with_wal_acceptors: bool):
|
||||
zenith_env_builder.pageserver_auth_enabled = True
|
||||
if with_wal_acceptors:
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
branch = f"test_compute_auth_to_pageserver{with_wal_acceptors}"
|
||||
env.zenith_cli.create_branch(branch, "main")
|
||||
|
||||
pg = env.postgres.create_start(branch)
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
cur.execute('CREATE TABLE t(key int primary key, value text)')
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
assert cur.fetchone() == (5000050000, )
|
||||
@@ -1,136 +0,0 @@
|
||||
import subprocess
|
||||
from contextlib import closing
|
||||
|
||||
import psycopg2.extras
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.utils import print_gc_result
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
|
||||
|
||||
#
|
||||
# Create a couple of branches off the main branch, at a historical point in time.
|
||||
#
|
||||
def test_branch_behind(zenith_env_builder: ZenithEnvBuilder):
|
||||
|
||||
# Use safekeeper in this test to avoid a subtle race condition.
|
||||
# Without safekeeper, walreceiver reconnection can stuck
|
||||
# because of IO deadlock.
|
||||
#
|
||||
# See https://github.com/zenithdb/zenith/issues/1068
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
# Branch at the point where only 100 rows were inserted
|
||||
env.zenith_cli.create_branch("test_branch_behind", "main")
|
||||
|
||||
pgmain = env.postgres.create_start('test_branch_behind')
|
||||
log.info("postgres is running on 'test_branch_behind' branch")
|
||||
|
||||
main_pg_conn = pgmain.connect()
|
||||
main_cur = main_pg_conn.cursor()
|
||||
|
||||
main_cur.execute("SHOW zenith.zenith_timeline")
|
||||
timeline = main_cur.fetchone()[0]
|
||||
|
||||
# Create table, and insert the first 100 rows
|
||||
main_cur.execute('CREATE TABLE foo (t text)')
|
||||
|
||||
# keep some early lsn to test branch creation on out of date lsn
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
gced_lsn = main_cur.fetchone()[0]
|
||||
|
||||
main_cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 100) g
|
||||
''')
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_a = main_cur.fetchone()[0]
|
||||
log.info(f'LSN after 100 rows: {lsn_a}')
|
||||
|
||||
# Insert some more rows. (This generates enough WAL to fill a few segments.)
|
||||
main_cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 200000) g
|
||||
''')
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_b = main_cur.fetchone()[0]
|
||||
log.info(f'LSN after 200100 rows: {lsn_b}')
|
||||
|
||||
# Branch at the point where only 100 rows were inserted
|
||||
env.zenith_cli.create_branch("test_branch_behind_hundred", "test_branch_behind@" + lsn_a)
|
||||
|
||||
# Insert many more rows. This generates enough WAL to fill a few segments.
|
||||
main_cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 200000) g
|
||||
''')
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_c = main_cur.fetchone()[0]
|
||||
log.info(f'LSN after 400100 rows: {lsn_c}')
|
||||
|
||||
# Branch at the point where only 200100 rows were inserted
|
||||
env.zenith_cli.create_branch("test_branch_behind_more", "test_branch_behind@" + lsn_b)
|
||||
|
||||
pg_hundred = env.postgres.create_start("test_branch_behind_hundred")
|
||||
pg_more = env.postgres.create_start("test_branch_behind_more")
|
||||
|
||||
# On the 'hundred' branch, we should see only 100 rows
|
||||
hundred_pg_conn = pg_hundred.connect()
|
||||
hundred_cur = hundred_pg_conn.cursor()
|
||||
hundred_cur.execute('SELECT count(*) FROM foo')
|
||||
assert hundred_cur.fetchone() == (100, )
|
||||
|
||||
# On the 'more' branch, we should see 100200 rows
|
||||
more_pg_conn = pg_more.connect()
|
||||
more_cur = more_pg_conn.cursor()
|
||||
more_cur.execute('SELECT count(*) FROM foo')
|
||||
assert more_cur.fetchone() == (200100, )
|
||||
|
||||
# All the rows are visible on the main branch
|
||||
main_cur.execute('SELECT count(*) FROM foo')
|
||||
assert main_cur.fetchone() == (400100, )
|
||||
|
||||
# Check bad lsn's for branching
|
||||
|
||||
# branch at segment boundary
|
||||
env.zenith_cli.create_branch("test_branch_segment_boundary", "test_branch_behind@0/3000000")
|
||||
pg = env.postgres.create_start("test_branch_segment_boundary")
|
||||
cur = pg.connect().cursor()
|
||||
cur.execute('SELECT 1')
|
||||
assert cur.fetchone() == (1, )
|
||||
|
||||
# branch at pre-initdb lsn
|
||||
with pytest.raises(Exception, match="invalid branch start lsn"):
|
||||
env.zenith_cli.create_branch("test_branch_preinitdb", "main@0/42")
|
||||
|
||||
# branch at pre-ancestor lsn
|
||||
with pytest.raises(Exception, match="less than timeline ancestor lsn"):
|
||||
env.zenith_cli.create_branch("test_branch_preinitdb", "test_branch_behind@0/42")
|
||||
|
||||
# check that we cannot create branch based on garbage collected data
|
||||
with closing(env.pageserver.connect()) as psconn:
|
||||
with psconn.cursor(cursor_factory=psycopg2.extras.DictCursor) as pscur:
|
||||
# call gc to advace latest_gc_cutoff_lsn
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
|
||||
with pytest.raises(Exception, match="invalid branch start lsn"):
|
||||
# this gced_lsn is pretty random, so if gc is disabled this woudln't fail
|
||||
env.zenith_cli.create_branch("test_branch_create_fail", f"test_branch_behind@{gced_lsn}")
|
||||
|
||||
# check that after gc everything is still there
|
||||
hundred_cur.execute('SELECT count(*) FROM foo')
|
||||
assert hundred_cur.fetchone() == (100, )
|
||||
|
||||
more_cur.execute('SELECT count(*) FROM foo')
|
||||
assert more_cur.fetchone() == (200100, )
|
||||
|
||||
main_cur.execute('SELECT count(*) FROM foo')
|
||||
assert main_cur.fetchone() == (400100, )
|
||||
@@ -1,74 +0,0 @@
|
||||
import time
|
||||
import os
|
||||
|
||||
from contextlib import closing
|
||||
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
#
|
||||
# Test compute node start after clog truncation
|
||||
#
|
||||
def test_clog_truncate(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_clog_truncate", "empty")
|
||||
|
||||
# set agressive autovacuum to make sure that truncation will happen
|
||||
config = [
|
||||
'autovacuum_max_workers=10',
|
||||
'autovacuum_vacuum_threshold=0',
|
||||
'autovacuum_vacuum_insert_threshold=0',
|
||||
'autovacuum_vacuum_cost_delay=0',
|
||||
'autovacuum_vacuum_cost_limit=10000',
|
||||
'autovacuum_naptime =1s',
|
||||
'autovacuum_freeze_max_age=100000'
|
||||
]
|
||||
|
||||
pg = env.postgres.create_start('test_clog_truncate', config_lines=config)
|
||||
log.info('postgres is running on test_clog_truncate branch')
|
||||
|
||||
# Install extension containing function needed for test
|
||||
pg.safe_psql('CREATE EXTENSION zenith_test_utils')
|
||||
|
||||
# Consume many xids to advance clog
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('select test_consume_xids(1000*1000*10);')
|
||||
log.info('xids consumed')
|
||||
|
||||
# call a checkpoint to trigger TruncateSubtrans
|
||||
cur.execute('CHECKPOINT;')
|
||||
|
||||
# ensure WAL flush
|
||||
cur.execute('select txid_current()')
|
||||
log.info(cur.fetchone())
|
||||
|
||||
# wait for autovacuum to truncate the pg_xact
|
||||
# XXX Is it worth to add a timeout here?
|
||||
pg_xact_0000_path = os.path.join(pg.pg_xact_dir_path(), '0000')
|
||||
log.info(f"pg_xact_0000_path = {pg_xact_0000_path}")
|
||||
|
||||
while os.path.isfile(pg_xact_0000_path):
|
||||
log.info(f"file exists. wait for truncation. " "pg_xact_0000_path = {pg_xact_0000_path}")
|
||||
time.sleep(5)
|
||||
|
||||
# checkpoint to advance latest lsn
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CHECKPOINT;')
|
||||
cur.execute('select pg_current_wal_insert_lsn()')
|
||||
lsn_after_truncation = cur.fetchone()[0]
|
||||
|
||||
# create new branch after clog truncation and start a compute node on it
|
||||
log.info(f'create branch at lsn_after_truncation {lsn_after_truncation}')
|
||||
env.zenith_cli.create_branch("test_clog_truncate_new",
|
||||
"test_clog_truncate@" + lsn_after_truncation)
|
||||
|
||||
pg2 = env.postgres.create_start('test_clog_truncate_new')
|
||||
log.info('postgres is running on test_clog_truncate_new branch')
|
||||
|
||||
# check that new node doesn't contain truncated segment
|
||||
pg_xact_0000_path_new = os.path.join(pg2.pg_xact_dir_path(), '0000')
|
||||
log.info(f"pg_xact_0000_path_new = {pg_xact_0000_path_new}")
|
||||
assert os.path.isfile(pg_xact_0000_path_new) is False
|
||||
@@ -1,93 +0,0 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from contextlib import closing
|
||||
from fixtures.zenith_fixtures import ZenithEnv, check_restored_datadir_content
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
#
|
||||
# Test CREATE DATABASE when there have been relmapper changes
|
||||
#
|
||||
def test_createdb(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_createdb", "empty")
|
||||
|
||||
pg = env.postgres.create_start('test_createdb')
|
||||
log.info("postgres is running on 'test_createdb' branch")
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Cause a 'relmapper' change in the original branch
|
||||
cur.execute('VACUUM FULL pg_class')
|
||||
|
||||
cur.execute('CREATE DATABASE foodb')
|
||||
|
||||
cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn = cur.fetchone()[0]
|
||||
|
||||
# Create a branch
|
||||
env.zenith_cli.create_branch("test_createdb2", "test_createdb@" + lsn)
|
||||
|
||||
pg2 = env.postgres.create_start('test_createdb2')
|
||||
|
||||
# Test that you can connect to the new database on both branches
|
||||
for db in (pg, pg2):
|
||||
db.connect(dbname='foodb').close()
|
||||
|
||||
|
||||
#
|
||||
# Test DROP DATABASE
|
||||
#
|
||||
def test_dropdb(zenith_simple_env: ZenithEnv, test_output_dir):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_dropdb", "empty")
|
||||
|
||||
pg = env.postgres.create_start('test_dropdb')
|
||||
log.info("postgres is running on 'test_dropdb' branch")
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE DATABASE foodb')
|
||||
|
||||
cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_before_drop = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT oid FROM pg_database WHERE datname='foodb';")
|
||||
dboid = cur.fetchone()[0]
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('DROP DATABASE foodb')
|
||||
|
||||
cur.execute('CHECKPOINT')
|
||||
|
||||
cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_after_drop = cur.fetchone()[0]
|
||||
|
||||
# Create two branches before and after database drop.
|
||||
env.zenith_cli.create_branch("test_before_dropdb", "test_dropdb@" + lsn_before_drop)
|
||||
pg_before = env.postgres.create_start('test_before_dropdb')
|
||||
|
||||
env.zenith_cli.create_branch("test_after_dropdb", "test_dropdb@" + lsn_after_drop)
|
||||
pg_after = env.postgres.create_start('test_after_dropdb')
|
||||
|
||||
# Test that database exists on the branch before drop
|
||||
pg_before.connect(dbname='foodb').close()
|
||||
|
||||
# Test that database subdir exists on the branch before drop
|
||||
assert pg_before.pgdata_dir
|
||||
dbpath = pathlib.Path(pg_before.pgdata_dir) / 'base' / str(dboid)
|
||||
log.info(dbpath)
|
||||
|
||||
assert os.path.isdir(dbpath) == True
|
||||
|
||||
# Test that database subdir doesn't exist on the branch after drop
|
||||
assert pg_after.pgdata_dir
|
||||
dbpath = pathlib.Path(pg_after.pgdata_dir) / 'base' / str(dboid)
|
||||
log.info(dbpath)
|
||||
|
||||
assert os.path.isdir(dbpath) == False
|
||||
|
||||
# Check that we restore the content of the datadir correctly
|
||||
check_restored_datadir_content(test_output_dir, env, pg)
|
||||
@@ -1,33 +0,0 @@
|
||||
from contextlib import closing
|
||||
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
#
|
||||
# Test CREATE USER to check shared catalog restore
|
||||
#
|
||||
def test_createuser(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_createuser", "empty")
|
||||
|
||||
pg = env.postgres.create_start('test_createuser')
|
||||
log.info("postgres is running on 'test_createuser' branch")
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Cause a 'relmapper' change in the original branch
|
||||
cur.execute('CREATE USER testuser with password %s', ('testpwd', ))
|
||||
|
||||
cur.execute('CHECKPOINT')
|
||||
|
||||
cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn = cur.fetchone()[0]
|
||||
|
||||
# Create a branch
|
||||
env.zenith_cli.create_branch("test_createuser2", "test_createuser@" + lsn)
|
||||
|
||||
pg2 = env.postgres.create_start('test_createuser2')
|
||||
|
||||
# Test that you can connect to new branch as a new user
|
||||
assert pg2.safe_psql('select current_user', username='testuser') == [('testuser', )]
|
||||
@@ -1,80 +0,0 @@
|
||||
from contextlib import closing
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import random
|
||||
|
||||
from fixtures.zenith_fixtures import ZenithEnv, Postgres, Safekeeper
|
||||
from fixtures.log_helper import log
|
||||
|
||||
# Test configuration
|
||||
#
|
||||
# Create a table with {num_rows} rows, and perform {updates_to_perform} random
|
||||
# UPDATEs on it, using {num_connections} separate connections.
|
||||
num_connections = 10
|
||||
num_rows = 100000
|
||||
updates_to_perform = 10000
|
||||
|
||||
updates_performed = 0
|
||||
|
||||
|
||||
# Run random UPDATEs on test table
|
||||
async def update_table(pg: Postgres):
|
||||
global updates_performed
|
||||
pg_conn = await pg.connect_async()
|
||||
|
||||
while updates_performed < updates_to_perform:
|
||||
updates_performed += 1
|
||||
id = random.randrange(1, num_rows)
|
||||
row = await pg_conn.fetchrow(f'UPDATE foo SET counter = counter + 1 WHERE id = {id}')
|
||||
|
||||
|
||||
# Perform aggressive GC with 0 horizon
|
||||
async def gc(env: ZenithEnv, timeline: str):
|
||||
psconn = await env.pageserver.connect_async()
|
||||
|
||||
while updates_performed < updates_to_perform:
|
||||
await psconn.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
|
||||
|
||||
# At the same time, run UPDATEs and GC
|
||||
async def update_and_gc(env: ZenithEnv, pg: Postgres, timeline: str):
|
||||
workers = []
|
||||
for worker_id in range(num_connections):
|
||||
workers.append(asyncio.create_task(update_table(pg)))
|
||||
workers.append(asyncio.create_task(gc(env, timeline)))
|
||||
|
||||
# await all workers
|
||||
await asyncio.gather(*workers)
|
||||
|
||||
|
||||
#
|
||||
# Aggressively force GC, while running queries.
|
||||
#
|
||||
# (repro for https://github.com/zenithdb/zenith/issues/1047)
|
||||
#
|
||||
def test_gc_aggressive(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_gc_aggressive", "empty")
|
||||
pg = env.postgres.create_start('test_gc_aggressive')
|
||||
log.info('postgres is running on test_gc_aggressive branch')
|
||||
|
||||
conn = pg.connect()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SHOW zenith.zenith_timeline")
|
||||
timeline = cur.fetchone()[0]
|
||||
|
||||
# Create table, and insert the first 100 rows
|
||||
cur.execute('CREATE TABLE foo (id int, counter int, t text)')
|
||||
cur.execute(f'''
|
||||
INSERT INTO foo
|
||||
SELECT g, 0, 'long string to consume some space' || g
|
||||
FROM generate_series(1, {num_rows}) g
|
||||
''')
|
||||
cur.execute('CREATE INDEX ON foo(id)')
|
||||
|
||||
asyncio.run(update_and_gc(env, pg, timeline))
|
||||
|
||||
row = cur.execute('SELECT COUNT(*), SUM(counter) FROM foo')
|
||||
assert cur.fetchone() == (num_rows, updates_to_perform)
|
||||
@@ -1,49 +0,0 @@
|
||||
import json
|
||||
from uuid import uuid4, UUID
|
||||
from fixtures.zenith_fixtures import ZenithEnv, ZenithEnvBuilder, ZenithPageserverHttpClient
|
||||
from typing import cast
|
||||
import pytest, psycopg2
|
||||
|
||||
|
||||
def check_client(client: ZenithPageserverHttpClient, initial_tenant: UUID):
|
||||
client.check_status()
|
||||
|
||||
# check initial tenant is there
|
||||
assert initial_tenant.hex in {t['id'] for t in client.tenant_list()}
|
||||
|
||||
# create new tenant and check it is also there
|
||||
tenant_id = uuid4()
|
||||
client.tenant_create(tenant_id)
|
||||
assert tenant_id.hex in {t['id'] for t in client.tenant_list()}
|
||||
|
||||
# check its timelines
|
||||
timelines = client.timeline_list(tenant_id)
|
||||
assert len(timelines) > 0
|
||||
for timeline_id_str in timelines:
|
||||
timeline_details = client.timeline_detail(tenant_id, UUID(timeline_id_str))
|
||||
assert timeline_details['type'] == 'Local'
|
||||
assert timeline_details['tenant_id'] == tenant_id.hex
|
||||
assert timeline_details['timeline_id'] == timeline_id_str
|
||||
|
||||
# create branch
|
||||
branch_name = uuid4().hex
|
||||
client.branch_create(tenant_id, branch_name, "main")
|
||||
|
||||
# check it is there
|
||||
assert branch_name in {b['name'] for b in client.branch_list(tenant_id)}
|
||||
|
||||
|
||||
def test_pageserver_http_api_client(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
client = env.pageserver.http_client()
|
||||
check_client(client, env.initial_tenant)
|
||||
|
||||
|
||||
def test_pageserver_http_api_client_auth_enabled(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.pageserver_auth_enabled = True
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
management_token = env.auth_keys.generate_management_token()
|
||||
|
||||
client = env.pageserver.http_client(auth_token=management_token)
|
||||
check_client(client, env.initial_tenant)
|
||||
@@ -1,63 +0,0 @@
|
||||
import pytest
|
||||
import random
|
||||
import time
|
||||
|
||||
from contextlib import closing
|
||||
from multiprocessing import Process, Value
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
# Test restarting page server, while safekeeper and compute node keep
|
||||
# running.
|
||||
def test_pageserver_restart(zenith_env_builder: ZenithEnvBuilder):
|
||||
# One safekeeper is enough for this test.
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_pageserver_restart", "main")
|
||||
pg = env.postgres.create_start('test_pageserver_restart')
|
||||
|
||||
pg_conn = pg.connect()
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# Create table, and insert some rows. Make it big enough that it doesn't fit in
|
||||
# shared_buffers, otherwise the SELECT after restart will just return answer
|
||||
# from shared_buffers without hitting the page server, which defeats the point
|
||||
# of this test.
|
||||
cur.execute('CREATE TABLE foo (t text)')
|
||||
cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 100000) g
|
||||
''')
|
||||
|
||||
# Verify that the table is larger than shared_buffers
|
||||
cur.execute('''
|
||||
select setting::int * pg_size_bytes(unit) as shared_buffers, pg_relation_size('foo') as tbl_ize
|
||||
from pg_settings where name = 'shared_buffers'
|
||||
''')
|
||||
row = cur.fetchone()
|
||||
log.info(f"shared_buffers is {row[0]}, table size {row[1]}")
|
||||
assert int(row[0]) < int(row[1])
|
||||
|
||||
# Stop and restart pageserver. This is a more or less graceful shutdown, although
|
||||
# the page server doesn't currently have a shutdown routine so there's no difference
|
||||
# between stopping and crashing.
|
||||
env.pageserver.stop()
|
||||
env.pageserver.start()
|
||||
|
||||
# Stopping the pageserver breaks the connection from the postgres backend to
|
||||
# the page server, and causes the next query on the connection to fail. Start a new
|
||||
# postgres connection too, to avoid that error. (Ideally, the compute node would
|
||||
# handle that and retry internally, without propagating the error to the user, but
|
||||
# currently it doesn't...)
|
||||
pg_conn = pg.connect()
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
cur.execute("SELECT count(*) FROM foo")
|
||||
assert cur.fetchone() == (100000, )
|
||||
|
||||
# Stop the page server by force, and restart it
|
||||
env.pageserver.stop()
|
||||
env.pageserver.start()
|
||||
@@ -1,14 +0,0 @@
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
def test_pgbench(zenith_simple_env: ZenithEnv, pg_bin):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_pgbench", "empty")
|
||||
pg = env.postgres.create_start('test_pgbench')
|
||||
log.info("postgres is running on 'test_pgbench' branch")
|
||||
|
||||
connstr = pg.connstr()
|
||||
|
||||
pg_bin.run_capture(['pgbench', '-i', connstr])
|
||||
pg_bin.run_capture(['pgbench'] + '-c 10 -T 5 -P 1 -M prepared'.split() + [connstr])
|
||||
@@ -1,2 +0,0 @@
|
||||
def test_proxy_select_1(static_proxy):
|
||||
static_proxy.safe_psql("select 1;")
|
||||
@@ -1,90 +0,0 @@
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
|
||||
|
||||
#
|
||||
# Create read-only compute nodes, anchored at historical points in time.
|
||||
#
|
||||
# This is very similar to the 'test_branch_behind' test, but instead of
|
||||
# creating branches, creates read-only nodes.
|
||||
#
|
||||
def test_readonly_node(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_readonly_node", "empty")
|
||||
|
||||
pgmain = env.postgres.create_start('test_readonly_node')
|
||||
log.info("postgres is running on 'test_readonly_node' branch")
|
||||
|
||||
main_pg_conn = pgmain.connect()
|
||||
main_cur = main_pg_conn.cursor()
|
||||
|
||||
# Create table, and insert the first 100 rows
|
||||
main_cur.execute('CREATE TABLE foo (t text)')
|
||||
|
||||
main_cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 100) g
|
||||
''')
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_a = main_cur.fetchone()[0]
|
||||
log.info('LSN after 100 rows: ' + lsn_a)
|
||||
|
||||
# Insert some more rows. (This generates enough WAL to fill a few segments.)
|
||||
main_cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 200000) g
|
||||
''')
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_b = main_cur.fetchone()[0]
|
||||
log.info('LSN after 200100 rows: ' + lsn_b)
|
||||
|
||||
# Insert many more rows. This generates enough WAL to fill a few segments.
|
||||
main_cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 200000) g
|
||||
''')
|
||||
|
||||
main_cur.execute('SELECT pg_current_wal_insert_lsn()')
|
||||
lsn_c = main_cur.fetchone()[0]
|
||||
log.info('LSN after 400100 rows: ' + lsn_c)
|
||||
|
||||
# Create first read-only node at the point where only 100 rows were inserted
|
||||
pg_hundred = env.postgres.create_start("test_readonly_node_hundred",
|
||||
branch=f'test_readonly_node@{lsn_a}')
|
||||
|
||||
# And another at the point where 200100 rows were inserted
|
||||
pg_more = env.postgres.create_start("test_readonly_node_more",
|
||||
branch=f'test_readonly_node@{lsn_b}')
|
||||
|
||||
# On the 'hundred' node, we should see only 100 rows
|
||||
hundred_pg_conn = pg_hundred.connect()
|
||||
hundred_cur = hundred_pg_conn.cursor()
|
||||
hundred_cur.execute('SELECT count(*) FROM foo')
|
||||
assert hundred_cur.fetchone() == (100, )
|
||||
|
||||
# On the 'more' node, we should see 100200 rows
|
||||
more_pg_conn = pg_more.connect()
|
||||
more_cur = more_pg_conn.cursor()
|
||||
more_cur.execute('SELECT count(*) FROM foo')
|
||||
assert more_cur.fetchone() == (200100, )
|
||||
|
||||
# All the rows are visible on the main branch
|
||||
main_cur.execute('SELECT count(*) FROM foo')
|
||||
assert main_cur.fetchone() == (400100, )
|
||||
|
||||
# Check creating a node at segment boundary
|
||||
pg = env.postgres.create_start("test_branch_segment_boundary",
|
||||
branch="test_readonly_node@0/3000000")
|
||||
cur = pg.connect().cursor()
|
||||
cur.execute('SELECT 1')
|
||||
assert cur.fetchone() == (1, )
|
||||
|
||||
# Create node at pre-initdb lsn
|
||||
with pytest.raises(Exception, match="invalid basebackup lsn"):
|
||||
# compute node startup with invalid LSN should fail
|
||||
env.zenith_cli.pg_start("test_readonly_node_preinitdb",
|
||||
timeline_spec="test_readonly_node@0/42")
|
||||
@@ -1,101 +0,0 @@
|
||||
# It's possible to run any regular test with the local fs remote storage via
|
||||
# env ZENITH_PAGESERVER_OVERRIDES="remote_storage={local_path='/tmp/zenith_zzz/'}" poetry ......
|
||||
|
||||
import time, shutil, os
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
from fixtures.log_helper import log
|
||||
import pytest
|
||||
|
||||
|
||||
#
|
||||
# Tests that a piece of data is backed up and restored correctly:
|
||||
#
|
||||
# 1. Initial pageserver
|
||||
# * starts a pageserver with remote storage, stores specific data in its tables
|
||||
# * triggers a checkpoint (which produces a local data scheduled for backup), gets the corresponding timeline id
|
||||
# * polls the timeline status to ensure it's copied remotely
|
||||
# * stops the pageserver, clears all local directories
|
||||
#
|
||||
# 2. Second pageserver
|
||||
# * starts another pageserver, connected to the same remote storage
|
||||
# * same timeline id is queried for status, triggering timeline's download
|
||||
# * timeline status is polled until it's downloaded
|
||||
# * queries the specific data, ensuring that it matches the one stored before
|
||||
#
|
||||
# The tests are done for all types of remote storage pageserver supports.
|
||||
@pytest.mark.skip(reason="will be fixed with https://github.com/zenithdb/zenith/issues/1193")
|
||||
@pytest.mark.parametrize('storage_type', ['local_fs', 'mock_s3'])
|
||||
def test_remote_storage_backup_and_restore(zenith_env_builder: ZenithEnvBuilder, storage_type: str):
|
||||
zenith_env_builder.rust_log_override = 'debug'
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
if storage_type == 'local_fs':
|
||||
zenith_env_builder.enable_local_fs_remote_storage()
|
||||
elif storage_type == 'mock_s3':
|
||||
zenith_env_builder.enable_s3_mock_remote_storage('test_remote_storage_backup_and_restore')
|
||||
else:
|
||||
raise RuntimeError(f'Unknown storage type: {storage_type}')
|
||||
|
||||
data_id = 1
|
||||
data_secret = 'very secret secret'
|
||||
|
||||
##### First start, insert secret data and upload it to the remote storage
|
||||
env = zenith_env_builder.init()
|
||||
pg = env.postgres.create_start()
|
||||
|
||||
tenant_id = pg.safe_psql("show zenith.zenith_tenant")[0][0]
|
||||
timeline_id = pg.safe_psql("show zenith.zenith_timeline")[0][0]
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'''
|
||||
CREATE TABLE t1(id int primary key, secret text);
|
||||
INSERT INTO t1 VALUES ({data_id}, '{data_secret}');
|
||||
''')
|
||||
|
||||
# run checkpoint manually to be sure that data landed in remote storage
|
||||
with closing(env.pageserver.connect()) as psconn:
|
||||
with psconn.cursor() as pscur:
|
||||
pscur.execute(f"do_gc {tenant_id} {timeline_id}")
|
||||
log.info("waiting for upload") # TODO api to check if upload is done
|
||||
time.sleep(2)
|
||||
|
||||
##### Stop the first pageserver instance, erase all its data
|
||||
env.postgres.stop_all()
|
||||
env.pageserver.stop()
|
||||
|
||||
dir_to_clear = Path(env.repo_dir) / 'tenants'
|
||||
shutil.rmtree(dir_to_clear)
|
||||
os.mkdir(dir_to_clear)
|
||||
|
||||
##### Second start, restore the data and ensure it's the same
|
||||
env.pageserver.start()
|
||||
|
||||
client = env.pageserver.http_client()
|
||||
client.timeline_attach(UUID(tenant_id), UUID(timeline_id))
|
||||
# FIXME cannot handle duplicate download requests (which might be caused by repeated timeline detail calls)
|
||||
# subject to fix in https://github.com/zenithdb/zenith/issues/997
|
||||
time.sleep(5)
|
||||
|
||||
log.info("waiting for timeline redownload")
|
||||
attempts = 0
|
||||
while True:
|
||||
timeline_details = client.timeline_detail(UUID(tenant_id), UUID(timeline_id))
|
||||
assert timeline_details['timeline_id'] == timeline_id
|
||||
assert timeline_details['tenant_id'] == tenant_id
|
||||
if timeline_details['type'] == 'Local':
|
||||
log.info("timeline downloaded, checking its data")
|
||||
break
|
||||
attempts += 1
|
||||
if attempts > 10:
|
||||
raise Exception("timeline redownload failed")
|
||||
log.debug("still waiting")
|
||||
time.sleep(1)
|
||||
|
||||
pg = env.postgres.create_start()
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT secret FROM t1 WHERE id = {data_id};')
|
||||
assert cur.fetchone() == (data_secret, )
|
||||
@@ -1,75 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from contextlib import closing
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
#
|
||||
# Test restarting and recreating a postgres instance
|
||||
#
|
||||
@pytest.mark.parametrize('with_wal_acceptors', [False, True])
|
||||
def test_restart_compute(zenith_env_builder: ZenithEnvBuilder, with_wal_acceptors: bool):
|
||||
zenith_env_builder.pageserver_auth_enabled = True
|
||||
if with_wal_acceptors:
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_restart_compute", "main")
|
||||
|
||||
pg = env.postgres.create_start('test_restart_compute')
|
||||
log.info("postgres is running on 'test_restart_compute' branch")
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE TABLE t(key int primary key, value text)')
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
r = cur.fetchone()
|
||||
assert r == (5000050000, )
|
||||
log.info(f"res = {r}")
|
||||
|
||||
# Remove data directory and restart
|
||||
pg.stop_and_destroy().create_start('test_restart_compute')
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# We can still see the row
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
r = cur.fetchone()
|
||||
assert r == (5000050000, )
|
||||
log.info(f"res = {r}")
|
||||
|
||||
# Insert another row
|
||||
cur.execute("INSERT INTO t VALUES (100001, 'payload2')")
|
||||
cur.execute('SELECT count(*) FROM t')
|
||||
|
||||
r = cur.fetchone()
|
||||
assert r == (100001, )
|
||||
log.info(f"res = {r}")
|
||||
|
||||
# Again remove data directory and restart
|
||||
pg.stop_and_destroy().create_start('test_restart_compute')
|
||||
|
||||
# That select causes lots of FPI's and increases probability of wakeepers
|
||||
# lagging behind after query completion
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# We can still see the rows
|
||||
cur.execute('SELECT count(*) FROM t')
|
||||
|
||||
r = cur.fetchone()
|
||||
assert r == (100001, )
|
||||
log.info(f"res = {r}")
|
||||
|
||||
# And again remove data directory and restart
|
||||
pg.stop_and_destroy().create_start('test_restart_compute')
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# We can still see the rows
|
||||
cur.execute('SELECT count(*) FROM t')
|
||||
|
||||
r = cur.fetchone()
|
||||
assert r == (100001, )
|
||||
log.info(f"res = {r}")
|
||||
@@ -1,131 +0,0 @@
|
||||
from contextlib import closing
|
||||
import psycopg2.extras
|
||||
import time
|
||||
from fixtures.utils import print_gc_result
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
#
|
||||
# Test Garbage Collection of old layer files
|
||||
#
|
||||
# This test is pretty tightly coupled with the current implementation of layered
|
||||
# storage, in layered_repository.rs.
|
||||
#
|
||||
def test_layerfiles_gc(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
env.zenith_cli.create_branch("test_layerfiles_gc", "empty")
|
||||
pg = env.postgres.create_start('test_layerfiles_gc')
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with closing(env.pageserver.connect()) as psconn:
|
||||
with psconn.cursor(cursor_factory=psycopg2.extras.DictCursor) as pscur:
|
||||
|
||||
# Get the timeline ID of our branch. We need it for the 'do_gc' command
|
||||
cur.execute("SHOW zenith.zenith_timeline")
|
||||
timeline = cur.fetchone()[0]
|
||||
|
||||
# Create a test table
|
||||
cur.execute("CREATE TABLE foo(x integer)")
|
||||
cur.execute("INSERT INTO foo VALUES (1)")
|
||||
|
||||
cur.execute("select relfilenode from pg_class where oid = 'foo'::regclass")
|
||||
row = cur.fetchone()
|
||||
log.info(f"relfilenode is {row[0]}")
|
||||
|
||||
# Run GC, to clear out any garbage left behind in the catalogs by
|
||||
# the CREATE TABLE command. We want to have a clean slate with no garbage
|
||||
# before running the actual tests below, otherwise the counts won't match
|
||||
# what we expect.
|
||||
#
|
||||
# Also run vacuum first to make it less likely that autovacuum or pruning
|
||||
# kicks in and confuses our numbers.
|
||||
cur.execute("VACUUM")
|
||||
|
||||
# delete the row, to update the Visibility Map. We don't want the VM
|
||||
# update to confuse our numbers either.
|
||||
cur.execute("DELETE FROM foo")
|
||||
|
||||
log.info("Running GC before test")
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
# remember the number of files
|
||||
layer_relfiles_remain = (row['layer_relfiles_total'] -
|
||||
row['layer_relfiles_removed'])
|
||||
assert layer_relfiles_remain > 0
|
||||
|
||||
# Insert a row and run GC. Checkpoint should freeze the layer
|
||||
# so that there is only the most recent image layer left for the rel,
|
||||
# removing the old image and delta layer.
|
||||
log.info("Inserting one row and running GC")
|
||||
cur.execute("INSERT INTO foo VALUES (1)")
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
assert row['layer_relfiles_total'] == layer_relfiles_remain + 2
|
||||
assert row['layer_relfiles_removed'] == 2
|
||||
assert row['layer_relfiles_dropped'] == 0
|
||||
|
||||
# Insert two more rows and run GC.
|
||||
# This should create new image and delta layer file with the new contents, and
|
||||
# then remove the old one image and the just-created delta layer.
|
||||
log.info("Inserting two more rows and running GC")
|
||||
cur.execute("INSERT INTO foo VALUES (2)")
|
||||
cur.execute("INSERT INTO foo VALUES (3)")
|
||||
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
assert row['layer_relfiles_total'] == layer_relfiles_remain + 2
|
||||
assert row['layer_relfiles_removed'] == 2
|
||||
assert row['layer_relfiles_dropped'] == 0
|
||||
|
||||
# Do it again. Should again create two new layer files and remove old ones.
|
||||
log.info("Inserting two more rows and running GC")
|
||||
cur.execute("INSERT INTO foo VALUES (2)")
|
||||
cur.execute("INSERT INTO foo VALUES (3)")
|
||||
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
assert row['layer_relfiles_total'] == layer_relfiles_remain + 2
|
||||
assert row['layer_relfiles_removed'] == 2
|
||||
assert row['layer_relfiles_dropped'] == 0
|
||||
|
||||
# Run GC again, with no changes in the database. Should not remove anything.
|
||||
log.info("Run GC again, with nothing to do")
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
assert row['layer_relfiles_total'] == layer_relfiles_remain
|
||||
assert row['layer_relfiles_removed'] == 0
|
||||
assert row['layer_relfiles_dropped'] == 0
|
||||
|
||||
#
|
||||
# Test DROP TABLE checks that relation data and metadata was deleted by GC from object storage
|
||||
#
|
||||
log.info("Drop table and run GC again")
|
||||
cur.execute("DROP TABLE foo")
|
||||
|
||||
pscur.execute(f"do_gc {env.initial_tenant.hex} {timeline} 0")
|
||||
row = pscur.fetchone()
|
||||
print_gc_result(row)
|
||||
|
||||
# We still cannot remove the latest layers
|
||||
# because they serve as tombstones for earlier layers.
|
||||
assert row['layer_relfiles_dropped'] == 0
|
||||
# Each relation fork is counted separately, hence 3.
|
||||
assert row['layer_relfiles_needed_as_tombstone'] == 3
|
||||
|
||||
# The catalog updates also create new layer files of the catalogs, which
|
||||
# are counted as 'removed'
|
||||
assert row['layer_relfiles_removed'] > 0
|
||||
|
||||
# TODO Change the test to check actual CG of dropped layers.
|
||||
# Each relation fork is counted separately, hence 3.
|
||||
#assert row['layer_relfiles_dropped'] == 3
|
||||
|
||||
# TODO: perhaps we should count catalog and user relations separately,
|
||||
# to make this kind of testing more robust
|
||||
@@ -1,267 +0,0 @@
|
||||
from contextlib import closing, contextmanager
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import threading
|
||||
from uuid import UUID
|
||||
from fixtures.log_helper import log
|
||||
import time
|
||||
import signal
|
||||
import pytest
|
||||
|
||||
from fixtures.zenith_fixtures import PgProtocol, PortDistributor, Postgres, ZenithEnvBuilder, ZenithPageserverHttpClient, zenith_binpath, pg_distrib_dir
|
||||
|
||||
|
||||
def assert_abs_margin_ratio(a: float, b: float, margin_ratio: float):
|
||||
assert abs(a - b) / a < margin_ratio, (a, b, margin_ratio)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def new_pageserver_helper(new_pageserver_dir: pathlib.Path,
|
||||
pageserver_bin: pathlib.Path,
|
||||
remote_storage_mock_path: pathlib.Path,
|
||||
pg_port: int,
|
||||
http_port: int):
|
||||
"""
|
||||
cannot use ZenithPageserver yet because it depends on zenith cli
|
||||
which currently lacks support for multiple pageservers
|
||||
"""
|
||||
cmd = [
|
||||
str(pageserver_bin),
|
||||
'--init',
|
||||
'--workdir',
|
||||
str(new_pageserver_dir),
|
||||
f"-c listen_pg_addr='localhost:{pg_port}'",
|
||||
f"-c listen_http_addr='localhost:{http_port}'",
|
||||
f"-c pg_distrib_dir='{pg_distrib_dir}'",
|
||||
f"-c remote_storage={{local_path='{remote_storage_mock_path}'}}",
|
||||
]
|
||||
|
||||
subprocess.check_output(cmd, text=True)
|
||||
|
||||
# actually run new pageserver
|
||||
cmd = [
|
||||
str(pageserver_bin),
|
||||
'--workdir',
|
||||
str(new_pageserver_dir),
|
||||
'--daemonize',
|
||||
]
|
||||
log.info("starting new pageserver %s", cmd)
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
log.info("started new pageserver %s", out)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
log.info("stopping new pageserver")
|
||||
pid = int((new_pageserver_dir / 'pageserver.pid').read_text())
|
||||
os.kill(pid, signal.SIGQUIT)
|
||||
|
||||
|
||||
def wait_for(number_of_iterations: int, interval: int, func):
|
||||
last_exception = None
|
||||
for i in range(number_of_iterations):
|
||||
try:
|
||||
res = func()
|
||||
except Exception as e:
|
||||
log.info("waiting for %s iteration %s failed", func, i + 1)
|
||||
last_exception = e
|
||||
time.sleep(interval)
|
||||
continue
|
||||
return res
|
||||
raise Exception("timed out while waiting for %s" % func) from last_exception
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pg_cur(pg):
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
|
||||
|
||||
def load(pg: Postgres, stop_event: threading.Event, load_ok_event: threading.Event):
|
||||
log.info("load started")
|
||||
|
||||
inserted_ctr = 0
|
||||
failed = False
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
with pg_cur(pg) as cur:
|
||||
cur.execute("INSERT INTO load VALUES ('some payload')")
|
||||
inserted_ctr += 1
|
||||
except:
|
||||
if not failed:
|
||||
log.info("load failed")
|
||||
failed = True
|
||||
load_ok_event.clear()
|
||||
else:
|
||||
if failed:
|
||||
with pg_cur(pg) as cur:
|
||||
# if we recovered after failure verify that we have correct number of rows
|
||||
log.info("recovering at %s", inserted_ctr)
|
||||
cur.execute("SELECT count(*) FROM load")
|
||||
# it seems that sometimes transaction gets commited before we can acknowledge
|
||||
# the result, so sometimes selected value is larger by one than we expect
|
||||
assert cur.fetchone()[0] - inserted_ctr <= 1
|
||||
log.info("successfully recovered %s", inserted_ctr)
|
||||
failed = False
|
||||
load_ok_event.set()
|
||||
log.info('load thread stopped')
|
||||
|
||||
|
||||
def assert_local(pageserver_http_client: ZenithPageserverHttpClient, tenant: UUID, timeline: str):
|
||||
timeline_detail = pageserver_http_client.timeline_detail(tenant, UUID(timeline))
|
||||
assert timeline_detail.get('type') == "Local", timeline_detail
|
||||
return timeline_detail
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="will be fixed with https://github.com/zenithdb/zenith/issues/1193")
|
||||
@pytest.mark.parametrize('with_load', ['with_load', 'without_load'])
|
||||
def test_tenant_relocation(zenith_env_builder: ZenithEnvBuilder,
|
||||
port_distributor: PortDistributor,
|
||||
with_load: str):
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
zenith_env_builder.enable_local_fs_remote_storage()
|
||||
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
# create folder for remote storage mock
|
||||
remote_storage_mock_path = env.repo_dir / 'local_fs_remote_storage'
|
||||
|
||||
tenant = env.create_tenant(UUID("74ee8b079a0e437eb0afea7d26a07209"))
|
||||
log.info("tenant to relocate %s", tenant)
|
||||
|
||||
env.zenith_cli.create_branch("test_tenant_relocation", "main", tenant_id=tenant)
|
||||
|
||||
tenant_pg = env.postgres.create_start(
|
||||
"test_tenant_relocation",
|
||||
"main", # branch name, None means same as node name
|
||||
tenant_id=tenant,
|
||||
)
|
||||
|
||||
# insert some data
|
||||
with closing(tenant_pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# save timeline for later gc call
|
||||
cur.execute("SHOW zenith.zenith_timeline")
|
||||
timeline = cur.fetchone()[0]
|
||||
log.info("timeline to relocate %s", timeline)
|
||||
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
cur.execute("CREATE TABLE t(key int primary key, value text)")
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,1000), 'some payload'")
|
||||
cur.execute("SELECT sum(key) FROM t")
|
||||
assert cur.fetchone() == (500500, )
|
||||
|
||||
if with_load == 'with_load':
|
||||
# create load table
|
||||
with pg_cur(tenant_pg) as cur:
|
||||
cur.execute("CREATE TABLE load(value text)")
|
||||
|
||||
load_stop_event = threading.Event()
|
||||
load_ok_event = threading.Event()
|
||||
load_thread = threading.Thread(target=load,
|
||||
args=(tenant_pg, load_stop_event, load_ok_event))
|
||||
load_thread.start()
|
||||
|
||||
# run checkpoint manually to be sure that data landed in remote storage
|
||||
with closing(env.pageserver.connect()) as psconn:
|
||||
with psconn.cursor() as pscur:
|
||||
pscur.execute(f"do_gc {tenant.hex} {timeline}")
|
||||
|
||||
# ensure upload is completed
|
||||
pageserver_http_client = env.pageserver.http_client()
|
||||
timeline_detail = pageserver_http_client.timeline_detail(tenant, UUID(timeline))
|
||||
assert timeline_detail['disk_consistent_lsn'] == timeline_detail['timeline_state']['Ready']
|
||||
|
||||
log.info("inititalizing new pageserver")
|
||||
# bootstrap second pageserver
|
||||
new_pageserver_dir = env.repo_dir / 'new_pageserver'
|
||||
new_pageserver_dir.mkdir()
|
||||
|
||||
new_pageserver_pg_port = port_distributor.get_port()
|
||||
new_pageserver_http_port = port_distributor.get_port()
|
||||
log.info("new pageserver ports pg %s http %s", new_pageserver_pg_port, new_pageserver_http_port)
|
||||
pageserver_bin = pathlib.Path(zenith_binpath) / 'pageserver'
|
||||
|
||||
new_pageserver_http_client = ZenithPageserverHttpClient(port=new_pageserver_http_port,
|
||||
auth_token=None)
|
||||
|
||||
with new_pageserver_helper(new_pageserver_dir,
|
||||
pageserver_bin,
|
||||
remote_storage_mock_path,
|
||||
new_pageserver_pg_port,
|
||||
new_pageserver_http_port):
|
||||
|
||||
# call to attach timeline to new pageserver
|
||||
new_pageserver_http_client.timeline_attach(tenant, UUID(timeline))
|
||||
# FIXME cannot handle duplicate download requests, subject to fix in https://github.com/zenithdb/zenith/issues/997
|
||||
time.sleep(5)
|
||||
# new pageserver should in sync (modulo wal tail or vacuum activity) with the old one because there was no new writes since checkpoint
|
||||
new_timeline_detail = wait_for(
|
||||
number_of_iterations=5,
|
||||
interval=1,
|
||||
func=lambda: assert_local(new_pageserver_http_client, tenant, timeline))
|
||||
assert new_timeline_detail['timeline_state'].get('Ready'), new_timeline_detail
|
||||
# when load is active these checks can break because lsns are not static
|
||||
# so lets check with some margin
|
||||
if with_load == 'without_load':
|
||||
# TODO revisit this once https://github.com/zenithdb/zenith/issues/1049 is fixed
|
||||
assert_abs_margin_ratio(new_timeline_detail['disk_consistent_lsn'],
|
||||
timeline_detail['disk_consistent_lsn'],
|
||||
0.01)
|
||||
assert_abs_margin_ratio(new_timeline_detail['timeline_state']['Ready'],
|
||||
timeline_detail['timeline_state']['Ready'],
|
||||
0.01)
|
||||
|
||||
# callmemaybe to start replication from safekeeper to the new pageserver
|
||||
# when there is no load there is a clean checkpoint and no wal delta
|
||||
# needs to be streamed to the new pageserver
|
||||
# TODO (rodionov) use attach to start replication
|
||||
with pg_cur(PgProtocol(host='localhost', port=new_pageserver_pg_port)) as cur:
|
||||
# "callmemaybe {} {} host={} port={} options='-c ztimelineid={} ztenantid={}'"
|
||||
safekeeper_connstring = f"host=localhost port={env.safekeepers[0].port.pg} options='-c ztimelineid={timeline} ztenantid={tenant} pageserver_connstr=postgresql://no_user:@localhost:{new_pageserver_pg_port}'"
|
||||
cur.execute("callmemaybe {} {} {}".format(tenant, timeline, safekeeper_connstring))
|
||||
|
||||
tenant_pg.stop()
|
||||
|
||||
# rewrite zenith cli config to use new pageserver for basebackup to start new compute
|
||||
cli_config_lines = (env.repo_dir / 'config').read_text().splitlines()
|
||||
cli_config_lines[-2] = f"listen_http_addr = 'localhost:{new_pageserver_http_port}'"
|
||||
cli_config_lines[-1] = f"listen_pg_addr = 'localhost:{new_pageserver_pg_port}'"
|
||||
(env.repo_dir / 'config').write_text('\n'.join(cli_config_lines))
|
||||
|
||||
tenant_pg_config_file_path = pathlib.Path(tenant_pg.config_file_path())
|
||||
tenant_pg_config_file_path.open('a').write(
|
||||
f"\nzenith.page_server_connstring = 'postgresql://no_user:@localhost:{new_pageserver_pg_port}'"
|
||||
)
|
||||
|
||||
tenant_pg.start()
|
||||
|
||||
# detach tenant from old pageserver before we check
|
||||
# that all the data is there to be sure that old pageserver
|
||||
# is no longer involved, and if it is, we will see the errors
|
||||
pageserver_http_client.timeline_detach(tenant, UUID(timeline))
|
||||
|
||||
with pg_cur(tenant_pg) as cur:
|
||||
# check that data is still there
|
||||
cur.execute("SELECT sum(key) FROM t")
|
||||
assert cur.fetchone() == (500500, )
|
||||
# check that we can write new data
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1001,2000), 'some payload'")
|
||||
cur.execute("SELECT sum(key) FROM t")
|
||||
assert cur.fetchone() == (2001000, )
|
||||
|
||||
if with_load == 'with_load':
|
||||
assert load_ok_event.wait(1)
|
||||
log.info('stopping load thread')
|
||||
load_stop_event.set()
|
||||
load_thread.join()
|
||||
log.info('load thread stopped')
|
||||
|
||||
# bring old pageserver back for clean shutdown via zenith cli
|
||||
# new pageserver will be shut down by the context manager
|
||||
cli_config_lines = (env.repo_dir / 'config').read_text().splitlines()
|
||||
cli_config_lines[-2] = f"listen_http_addr = 'localhost:{env.pageserver.service_port.http}'"
|
||||
cli_config_lines[-1] = f"listen_pg_addr = 'localhost:{env.pageserver.service_port.pg}'"
|
||||
(env.repo_dir / 'config').write_text('\n'.join(cli_config_lines))
|
||||
@@ -1,44 +0,0 @@
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
|
||||
|
||||
@pytest.mark.parametrize('with_wal_acceptors', [False, True])
|
||||
def test_tenants_normal_work(zenith_env_builder: ZenithEnvBuilder, with_wal_acceptors: bool):
|
||||
if with_wal_acceptors:
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
|
||||
env = zenith_env_builder.init()
|
||||
"""Tests tenants with and without wal acceptors"""
|
||||
tenant_1 = env.create_tenant()
|
||||
tenant_2 = env.create_tenant()
|
||||
|
||||
env.zenith_cli.create_branch(f"test_tenants_normal_work_with_wal_acceptors{with_wal_acceptors}",
|
||||
"main",
|
||||
tenant_id=tenant_1)
|
||||
env.zenith_cli.create_branch(f"test_tenants_normal_work_with_wal_acceptors{with_wal_acceptors}",
|
||||
"main",
|
||||
tenant_id=tenant_2)
|
||||
|
||||
pg_tenant1 = env.postgres.create_start(
|
||||
f"test_tenants_normal_work_with_wal_acceptors{with_wal_acceptors}",
|
||||
None, # branch name, None means same as node name
|
||||
tenant_1,
|
||||
)
|
||||
pg_tenant2 = env.postgres.create_start(
|
||||
f"test_tenants_normal_work_with_wal_acceptors{with_wal_acceptors}",
|
||||
None, # branch name, None means same as node name
|
||||
tenant_2,
|
||||
)
|
||||
|
||||
for pg in [pg_tenant1, pg_tenant2]:
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
cur.execute("CREATE TABLE t(key int primary key, value text)")
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
cur.execute("SELECT sum(key) FROM t")
|
||||
assert cur.fetchone() == (5000050000, )
|
||||
@@ -1,132 +0,0 @@
|
||||
from contextlib import closing
|
||||
from uuid import UUID
|
||||
import psycopg2.extras
|
||||
import psycopg2.errors
|
||||
from fixtures.zenith_fixtures import ZenithEnv, ZenithEnvBuilder, Postgres
|
||||
from fixtures.log_helper import log
|
||||
import time
|
||||
|
||||
|
||||
def test_timeline_size(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
# Branch at the point where only 100 rows were inserted
|
||||
env.zenith_cli.create_branch("test_timeline_size", "empty")
|
||||
|
||||
client = env.pageserver.http_client()
|
||||
res = client.branch_detail(env.initial_tenant, "test_timeline_size")
|
||||
assert res["current_logical_size"] == res["current_logical_size_non_incremental"]
|
||||
|
||||
pgmain = env.postgres.create_start("test_timeline_size")
|
||||
log.info("postgres is running on 'test_timeline_size' branch")
|
||||
|
||||
with closing(pgmain.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SHOW zenith.zenith_timeline")
|
||||
|
||||
# Create table, and insert the first 100 rows
|
||||
cur.execute("CREATE TABLE foo (t text)")
|
||||
cur.execute("""
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 10) g
|
||||
""")
|
||||
|
||||
res = client.branch_detail(env.initial_tenant, "test_timeline_size")
|
||||
assert res["current_logical_size"] == res["current_logical_size_non_incremental"]
|
||||
cur.execute("TRUNCATE foo")
|
||||
|
||||
res = client.branch_detail(env.initial_tenant, "test_timeline_size")
|
||||
assert res["current_logical_size"] == res["current_logical_size_non_incremental"]
|
||||
|
||||
|
||||
# wait until received_lsn_lag is 0
|
||||
def wait_for_pageserver_catchup(pgmain: Postgres, polling_interval=1, timeout=60):
|
||||
started_at = time.time()
|
||||
|
||||
received_lsn_lag = 1
|
||||
while received_lsn_lag > 0:
|
||||
elapsed = time.time() - started_at
|
||||
if elapsed > timeout:
|
||||
raise RuntimeError(
|
||||
f"timed out waiting for pageserver to reach pg_current_wal_flush_lsn()")
|
||||
|
||||
with closing(pgmain.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
|
||||
cur.execute('''
|
||||
select pg_size_pretty(pg_cluster_size()),
|
||||
pg_wal_lsn_diff(pg_current_wal_flush_lsn(),received_lsn) as received_lsn_lag
|
||||
FROM backpressure_lsns();
|
||||
''')
|
||||
res = cur.fetchone()
|
||||
log.info(f"pg_cluster_size = {res[0]}, received_lsn_lag = {res[1]}")
|
||||
received_lsn_lag = res[1]
|
||||
|
||||
time.sleep(polling_interval)
|
||||
|
||||
|
||||
def test_timeline_size_quota(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
env.zenith_cli.create_branch("test_timeline_size_quota", "main")
|
||||
|
||||
client = env.pageserver.http_client()
|
||||
res = client.branch_detail(env.initial_tenant, "test_timeline_size_quota")
|
||||
assert res["current_logical_size"] == res["current_logical_size_non_incremental"]
|
||||
|
||||
pgmain = env.postgres.create_start(
|
||||
"test_timeline_size_quota",
|
||||
# Set small limit for the test
|
||||
config_lines=['zenith.max_cluster_size=30MB'],
|
||||
)
|
||||
log.info("postgres is running on 'test_timeline_size_quota' branch")
|
||||
|
||||
with closing(pgmain.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION zenith") # TODO move it to zenith_fixtures?
|
||||
|
||||
cur.execute("CREATE TABLE foo (t text)")
|
||||
|
||||
wait_for_pageserver_catchup(pgmain)
|
||||
|
||||
# Insert many rows. This query must fail because of space limit
|
||||
try:
|
||||
cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 100000) g
|
||||
''')
|
||||
|
||||
wait_for_pageserver_catchup(pgmain)
|
||||
|
||||
cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 500000) g
|
||||
''')
|
||||
|
||||
# If we get here, the timeline size limit failed
|
||||
log.error("Query unexpectedly succeeded")
|
||||
assert False
|
||||
|
||||
except psycopg2.errors.DiskFull as err:
|
||||
log.info(f"Query expectedly failed with: {err}")
|
||||
|
||||
# drop table to free space
|
||||
cur.execute('DROP TABLE foo')
|
||||
|
||||
wait_for_pageserver_catchup(pgmain)
|
||||
|
||||
# create it again and insert some rows. This query must succeed
|
||||
cur.execute("CREATE TABLE foo (t text)")
|
||||
cur.execute('''
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 10000) g
|
||||
''')
|
||||
|
||||
wait_for_pageserver_catchup(pgmain)
|
||||
|
||||
cur.execute("SELECT * from pg_size_pretty(pg_cluster_size())")
|
||||
pg_cluster_size = cur.fetchone()
|
||||
log.info(f"pg_cluster_size = {pg_cluster_size}")
|
||||
@@ -1,79 +0,0 @@
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
#
|
||||
# Test that the VM bit is cleared correctly at a HEAP_DELETE and
|
||||
# HEAP_UPDATE record.
|
||||
#
|
||||
def test_vm_bit_clear(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
|
||||
env.zenith_cli.create_branch("test_vm_bit_clear", "empty")
|
||||
pg = env.postgres.create_start('test_vm_bit_clear')
|
||||
|
||||
log.info("postgres is running on 'test_vm_bit_clear' branch")
|
||||
pg_conn = pg.connect()
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# Install extension containing function needed for test
|
||||
cur.execute('CREATE EXTENSION zenith_test_utils')
|
||||
|
||||
# Create a test table and freeze it to set the VM bit.
|
||||
cur.execute('CREATE TABLE vmtest_delete (id integer PRIMARY KEY)')
|
||||
cur.execute('INSERT INTO vmtest_delete VALUES (1)')
|
||||
cur.execute('VACUUM FREEZE vmtest_delete')
|
||||
|
||||
cur.execute('CREATE TABLE vmtest_update (id integer PRIMARY KEY)')
|
||||
cur.execute('INSERT INTO vmtest_update SELECT g FROM generate_series(1, 1000) g')
|
||||
cur.execute('VACUUM FREEZE vmtest_update')
|
||||
|
||||
# DELETE and UDPATE the rows.
|
||||
cur.execute('DELETE FROM vmtest_delete WHERE id = 1')
|
||||
cur.execute('UPDATE vmtest_update SET id = 5000 WHERE id = 1')
|
||||
|
||||
# Branch at this point, to test that later
|
||||
env.zenith_cli.create_branch("test_vm_bit_clear_new", "test_vm_bit_clear")
|
||||
|
||||
# Clear the buffer cache, to force the VM page to be re-fetched from
|
||||
# the page server
|
||||
cur.execute('SELECT clear_buffer_cache()')
|
||||
|
||||
# Check that an index-only scan doesn't see the deleted row. If the
|
||||
# clearing of the VM bit was not replayed correctly, this would incorrectly
|
||||
# return deleted row.
|
||||
cur.execute('''
|
||||
set enable_seqscan=off;
|
||||
set enable_indexscan=on;
|
||||
set enable_bitmapscan=off;
|
||||
''')
|
||||
|
||||
cur.execute('SELECT * FROM vmtest_delete WHERE id = 1')
|
||||
assert (cur.fetchall() == [])
|
||||
cur.execute('SELECT * FROM vmtest_update WHERE id = 1')
|
||||
assert (cur.fetchall() == [])
|
||||
|
||||
cur.close()
|
||||
|
||||
# Check the same thing on the branch that we created right after the DELETE
|
||||
#
|
||||
# As of this writing, the code in smgrwrite() creates a full-page image whenever
|
||||
# a dirty VM page is evicted. If the VM bit was not correctly cleared by the
|
||||
# earlier WAL record, the full-page image hides the problem. Starting a new
|
||||
# server at the right point-in-time avoids that full-page image.
|
||||
pg_new = env.postgres.create_start('test_vm_bit_clear_new')
|
||||
|
||||
log.info("postgres is running on 'test_vm_bit_clear_new' branch")
|
||||
pg_new_conn = pg_new.connect()
|
||||
cur_new = pg_new_conn.cursor()
|
||||
|
||||
cur_new.execute('''
|
||||
set enable_seqscan=off;
|
||||
set enable_indexscan=on;
|
||||
set enable_bitmapscan=off;
|
||||
''')
|
||||
|
||||
cur_new.execute('SELECT * FROM vmtest_delete WHERE id = 1')
|
||||
assert (cur_new.fetchall() == [])
|
||||
cur_new.execute('SELECT * FROM vmtest_update WHERE id = 1')
|
||||
assert (cur_new.fetchall() == [])
|
||||
@@ -1,692 +0,0 @@
|
||||
import pytest
|
||||
import random
|
||||
import time
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass, field
|
||||
from multiprocessing import Process, Value
|
||||
from pathlib import Path
|
||||
from fixtures.zenith_fixtures import PgBin, Postgres, Safekeeper, ZenithEnv, ZenithEnvBuilder, PortDistributor, SafekeeperPort, zenith_binpath, PgProtocol
|
||||
from fixtures.utils import lsn_to_hex, mkdir_if_needed
|
||||
from fixtures.log_helper import log
|
||||
from typing import List, Optional, Any
|
||||
|
||||
|
||||
# basic test, write something in setup with wal acceptors, ensure that commits
|
||||
# succeed and data is written
|
||||
def test_normal_work(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_wal_acceptors_normal_work", "main")
|
||||
|
||||
pg = env.postgres.create_start('test_wal_acceptors_normal_work')
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
cur.execute('CREATE TABLE t(key int primary key, value text)')
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
assert cur.fetchone() == (5000050000, )
|
||||
|
||||
|
||||
@dataclass
|
||||
class BranchMetrics:
|
||||
name: str
|
||||
latest_valid_lsn: int
|
||||
# One entry per each Safekeeper, order is the same
|
||||
flush_lsns: List[int] = field(default_factory=list)
|
||||
commit_lsns: List[int] = field(default_factory=list)
|
||||
|
||||
|
||||
# Run page server and multiple acceptors, and multiple compute nodes running
|
||||
# against different timelines.
|
||||
def test_many_timelines(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
n_timelines = 3
|
||||
|
||||
branches = ["test_wal_acceptors_many_timelines_{}".format(tlin) for tlin in range(n_timelines)]
|
||||
|
||||
# start postgres on each timeline
|
||||
pgs = []
|
||||
for branch in branches:
|
||||
env.zenith_cli.create_branch(branch, "main")
|
||||
pgs.append(env.postgres.create_start(branch))
|
||||
|
||||
tenant_id = env.initial_tenant
|
||||
|
||||
def collect_metrics(message: str) -> List[BranchMetrics]:
|
||||
with env.pageserver.http_client() as pageserver_http:
|
||||
branch_details = [
|
||||
pageserver_http.branch_detail(tenant_id=tenant_id, name=branch)
|
||||
for branch in branches
|
||||
]
|
||||
# All changes visible to pageserver (latest_valid_lsn) should be
|
||||
# confirmed by safekeepers first. As we cannot atomically get
|
||||
# state of both pageserver and safekeepers, we should start with
|
||||
# pageserver. Looking at outdated data from pageserver is ok.
|
||||
# Asking safekeepers first is not ok because new commits may arrive
|
||||
# to both safekeepers and pageserver after we've already obtained
|
||||
# safekeepers' state, it will look contradictory.
|
||||
sk_metrics = [sk.http_client().get_metrics() for sk in env.safekeepers]
|
||||
|
||||
branch_metrics = []
|
||||
with env.pageserver.http_client() as pageserver_http:
|
||||
for branch_detail in branch_details:
|
||||
timeline_id: str = branch_detail["timeline_id"]
|
||||
|
||||
m = BranchMetrics(
|
||||
name=branch_detail["name"],
|
||||
latest_valid_lsn=branch_detail["latest_valid_lsn"],
|
||||
)
|
||||
for sk_m in sk_metrics:
|
||||
m.flush_lsns.append(sk_m.flush_lsn_inexact[(tenant_id.hex, timeline_id)])
|
||||
m.commit_lsns.append(sk_m.commit_lsn_inexact[(tenant_id.hex, timeline_id)])
|
||||
|
||||
for flush_lsn, commit_lsn in zip(m.flush_lsns, m.commit_lsns):
|
||||
# Invariant. May be < when transaction is in progress.
|
||||
assert commit_lsn <= flush_lsn
|
||||
# We only call collect_metrics() after a transaction is confirmed by
|
||||
# the compute node, which only happens after a consensus of safekeepers
|
||||
# has confirmed the transaction. We assume majority consensus here.
|
||||
assert (2 * sum(m.latest_valid_lsn <= lsn
|
||||
for lsn in m.flush_lsns) > zenith_env_builder.num_safekeepers)
|
||||
assert (2 * sum(m.latest_valid_lsn <= lsn
|
||||
for lsn in m.commit_lsns) > zenith_env_builder.num_safekeepers)
|
||||
branch_metrics.append(m)
|
||||
log.info(f"{message}: {branch_metrics}")
|
||||
return branch_metrics
|
||||
|
||||
# TODO: https://github.com/zenithdb/zenith/issues/809
|
||||
# collect_metrics("before CREATE TABLE")
|
||||
|
||||
# Do everything in different loops to have actions on different timelines
|
||||
# interleaved.
|
||||
# create schema
|
||||
for pg in pgs:
|
||||
pg.safe_psql("CREATE TABLE t(key int primary key, value text)")
|
||||
init_m = collect_metrics("after CREATE TABLE")
|
||||
|
||||
# Populate data for 2/3 branches
|
||||
class MetricsChecker(threading.Thread):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self.should_stop = threading.Event()
|
||||
self.exception: Optional[BaseException] = None
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
while not self.should_stop.is_set():
|
||||
collect_metrics("during INSERT INTO")
|
||||
time.sleep(1)
|
||||
except:
|
||||
log.error("MetricsChecker's thread failed, the test will be failed on .stop() call",
|
||||
exc_info=True)
|
||||
# We want to preserve traceback as well as the exception
|
||||
exc_type, exc_value, exc_tb = sys.exc_info()
|
||||
assert exc_type
|
||||
e = exc_type(exc_value)
|
||||
e.__traceback__ = exc_tb
|
||||
self.exception = e
|
||||
|
||||
def stop(self) -> None:
|
||||
self.should_stop.set()
|
||||
self.join()
|
||||
if self.exception:
|
||||
raise self.exception
|
||||
|
||||
metrics_checker = MetricsChecker()
|
||||
metrics_checker.start()
|
||||
|
||||
for pg in pgs[:-1]:
|
||||
pg.safe_psql("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
|
||||
metrics_checker.stop()
|
||||
|
||||
collect_metrics("after INSERT INTO")
|
||||
|
||||
# Check data for 2/3 branches
|
||||
for pg in pgs[:-1]:
|
||||
res = pg.safe_psql("SELECT sum(key) FROM t")
|
||||
assert res[0] == (5000050000, )
|
||||
|
||||
final_m = collect_metrics("after SELECT")
|
||||
# Assume that LSNs (a) behave similarly in all branches; and (b) INSERT INTO alters LSN significantly.
|
||||
# Also assume that safekeepers will not be significantly out of sync in this test.
|
||||
middle_lsn = (init_m[0].latest_valid_lsn + final_m[0].latest_valid_lsn) // 2
|
||||
assert max(init_m[0].flush_lsns) < middle_lsn < min(final_m[0].flush_lsns)
|
||||
assert max(init_m[0].commit_lsns) < middle_lsn < min(final_m[0].commit_lsns)
|
||||
assert max(init_m[1].flush_lsns) < middle_lsn < min(final_m[1].flush_lsns)
|
||||
assert max(init_m[1].commit_lsns) < middle_lsn < min(final_m[1].commit_lsns)
|
||||
assert max(init_m[2].flush_lsns) <= min(final_m[2].flush_lsns) < middle_lsn
|
||||
assert max(init_m[2].commit_lsns) <= min(final_m[2].commit_lsns) < middle_lsn
|
||||
|
||||
|
||||
# Check that dead minority doesn't prevent the commits: execute insert n_inserts
|
||||
# times, with fault_probability chance of getting a wal acceptor down or up
|
||||
# along the way. 2 of 3 are always alive, so the work keeps going.
|
||||
def test_restarts(zenith_env_builder: ZenithEnvBuilder):
|
||||
fault_probability = 0.01
|
||||
n_inserts = 1000
|
||||
n_acceptors = 3
|
||||
|
||||
zenith_env_builder.num_safekeepers = n_acceptors
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_wal_acceptors_restarts", "main")
|
||||
pg = env.postgres.create_start('test_wal_acceptors_restarts')
|
||||
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
pg_conn = pg.connect()
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
failed_node = None
|
||||
cur.execute('CREATE TABLE t(key int primary key, value text)')
|
||||
for i in range(n_inserts):
|
||||
cur.execute("INSERT INTO t values (%s, 'payload');", (i + 1, ))
|
||||
|
||||
if random.random() <= fault_probability:
|
||||
if failed_node is None:
|
||||
failed_node = env.safekeepers[random.randrange(0, n_acceptors)]
|
||||
failed_node.stop()
|
||||
else:
|
||||
failed_node.start()
|
||||
failed_node = None
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
assert cur.fetchone() == (500500, )
|
||||
|
||||
|
||||
start_delay_sec = 2
|
||||
|
||||
|
||||
def delayed_wal_acceptor_start(wa):
|
||||
time.sleep(start_delay_sec)
|
||||
wa.start()
|
||||
|
||||
|
||||
# When majority of acceptors is offline, commits are expected to be frozen
|
||||
def test_unavailability(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.num_safekeepers = 2
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_wal_acceptors_unavailability", "main")
|
||||
pg = env.postgres.create_start('test_wal_acceptors_unavailability')
|
||||
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
pg_conn = pg.connect()
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# check basic work with table
|
||||
cur.execute('CREATE TABLE t(key int primary key, value text)')
|
||||
cur.execute("INSERT INTO t values (1, 'payload')")
|
||||
|
||||
# shutdown one of two acceptors, that is, majority
|
||||
env.safekeepers[0].stop()
|
||||
|
||||
proc = Process(target=delayed_wal_acceptor_start, args=(env.safekeepers[0], ))
|
||||
proc.start()
|
||||
|
||||
start = time.time()
|
||||
cur.execute("INSERT INTO t values (2, 'payload')")
|
||||
# ensure that the query above was hanging while acceptor was down
|
||||
assert (time.time() - start) >= start_delay_sec
|
||||
proc.join()
|
||||
|
||||
# for the world's balance, do the same with second acceptor
|
||||
env.safekeepers[1].stop()
|
||||
|
||||
proc = Process(target=delayed_wal_acceptor_start, args=(env.safekeepers[1], ))
|
||||
proc.start()
|
||||
|
||||
start = time.time()
|
||||
cur.execute("INSERT INTO t values (3, 'payload')")
|
||||
# ensure that the query above was hanging while acceptor was down
|
||||
assert (time.time() - start) >= start_delay_sec
|
||||
proc.join()
|
||||
|
||||
cur.execute("INSERT INTO t values (4, 'payload')")
|
||||
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
assert cur.fetchone() == (10, )
|
||||
|
||||
|
||||
# shut down random subset of acceptors, sleep, wake them up, rinse, repeat
|
||||
def xmas_garland(acceptors, stop):
|
||||
while not bool(stop.value):
|
||||
victims = []
|
||||
for wa in acceptors:
|
||||
if random.random() >= 0.5:
|
||||
victims.append(wa)
|
||||
for v in victims:
|
||||
v.stop()
|
||||
time.sleep(1)
|
||||
for v in victims:
|
||||
v.start()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
# value which gets unset on exit
|
||||
@pytest.fixture
|
||||
def stop_value():
|
||||
stop = Value('i', 0)
|
||||
yield stop
|
||||
stop.value = 1
|
||||
|
||||
|
||||
# do inserts while concurrently getting up/down subsets of acceptors
|
||||
def test_race_conditions(zenith_env_builder: ZenithEnvBuilder, stop_value):
|
||||
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_wal_acceptors_race_conditions", "main")
|
||||
pg = env.postgres.create_start('test_wal_acceptors_race_conditions')
|
||||
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
pg_conn = pg.connect()
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
cur.execute('CREATE TABLE t(key int primary key, value text)')
|
||||
|
||||
proc = Process(target=xmas_garland, args=(env.safekeepers, stop_value))
|
||||
proc.start()
|
||||
|
||||
for i in range(1000):
|
||||
cur.execute("INSERT INTO t values (%s, 'payload');", (i + 1, ))
|
||||
|
||||
cur.execute('SELECT sum(key) FROM t')
|
||||
assert cur.fetchone() == (500500, )
|
||||
|
||||
stop_value.value = 1
|
||||
proc.join()
|
||||
|
||||
|
||||
class ProposerPostgres(PgProtocol):
|
||||
"""Object for running postgres without ZenithEnv"""
|
||||
def __init__(self,
|
||||
pgdata_dir: str,
|
||||
pg_bin,
|
||||
timeline_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
listen_addr: str,
|
||||
port: int):
|
||||
super().__init__(host=listen_addr, port=port, username='zenith_admin')
|
||||
|
||||
self.pgdata_dir: str = pgdata_dir
|
||||
self.pg_bin: PgBin = pg_bin
|
||||
self.timeline_id: uuid.UUID = timeline_id
|
||||
self.tenant_id: uuid.UUID = tenant_id
|
||||
self.listen_addr: str = listen_addr
|
||||
self.port: int = port
|
||||
|
||||
def pg_data_dir_path(self) -> str:
|
||||
""" Path to data directory """
|
||||
return self.pgdata_dir
|
||||
|
||||
def config_file_path(self) -> str:
|
||||
""" Path to postgresql.conf """
|
||||
return os.path.join(self.pgdata_dir, 'postgresql.conf')
|
||||
|
||||
def create_dir_config(self, wal_acceptors: str):
|
||||
""" Create dir and config for running --sync-safekeepers """
|
||||
|
||||
mkdir_if_needed(self.pg_data_dir_path())
|
||||
with open(self.config_file_path(), "w") as f:
|
||||
cfg = [
|
||||
"synchronous_standby_names = 'walproposer'\n",
|
||||
"shared_preload_libraries = 'zenith'\n",
|
||||
f"zenith.zenith_timeline = '{self.timeline_id.hex}'\n",
|
||||
f"zenith.zenith_tenant = '{self.tenant_id.hex}'\n",
|
||||
f"zenith.page_server_connstring = ''\n",
|
||||
f"wal_acceptors = '{wal_acceptors}'\n",
|
||||
f"listen_addresses = '{self.listen_addr}'\n",
|
||||
f"port = '{self.port}'\n",
|
||||
]
|
||||
|
||||
f.writelines(cfg)
|
||||
|
||||
def sync_safekeepers(self) -> str:
|
||||
"""
|
||||
Run 'postgres --sync-safekeepers'.
|
||||
Returns execution result, which is commit_lsn after sync.
|
||||
"""
|
||||
|
||||
command = ["postgres", "--sync-safekeepers"]
|
||||
env = {
|
||||
"PGDATA": self.pg_data_dir_path(),
|
||||
}
|
||||
|
||||
basepath = self.pg_bin.run_capture(command, env)
|
||||
stdout_filename = basepath + '.stdout'
|
||||
|
||||
with open(stdout_filename, 'r') as stdout_f:
|
||||
stdout = stdout_f.read()
|
||||
return stdout.strip("\n ")
|
||||
|
||||
def initdb(self):
|
||||
""" Run initdb """
|
||||
|
||||
args = ["initdb", "-U", "zenith_admin", "-D", self.pg_data_dir_path()]
|
||||
self.pg_bin.run(args)
|
||||
|
||||
def start(self):
|
||||
""" Start postgres with pg_ctl """
|
||||
|
||||
log_path = os.path.join(self.pg_data_dir_path(), "pg.log")
|
||||
args = ["pg_ctl", "-D", self.pg_data_dir_path(), "-l", log_path, "-w", "start"]
|
||||
self.pg_bin.run(args)
|
||||
|
||||
def stop(self):
|
||||
""" Stop postgres with pg_ctl """
|
||||
|
||||
args = ["pg_ctl", "-D", self.pg_data_dir_path(), "-m", "immediate", "-w", "stop"]
|
||||
self.pg_bin.run(args)
|
||||
|
||||
|
||||
# insert wal in all safekeepers and run sync on proposer
|
||||
def test_sync_safekeepers(zenith_env_builder: ZenithEnvBuilder,
|
||||
pg_bin: PgBin,
|
||||
port_distributor: PortDistributor):
|
||||
|
||||
# We don't really need the full environment for this test, just the
|
||||
# safekeepers would be enough.
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
timeline_id = uuid.uuid4()
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
# write config for proposer
|
||||
pgdata_dir = os.path.join(env.repo_dir, "proposer_pgdata")
|
||||
pg = ProposerPostgres(pgdata_dir,
|
||||
pg_bin,
|
||||
timeline_id,
|
||||
tenant_id,
|
||||
'127.0.0.1',
|
||||
port_distributor.get_port())
|
||||
pg.create_dir_config(env.get_safekeeper_connstrs())
|
||||
|
||||
# valid lsn, which is not in the segment start, nor in zero segment
|
||||
epoch_start_lsn = 0x16B9188 # 0/16B9188
|
||||
begin_lsn = epoch_start_lsn
|
||||
|
||||
# append and commit WAL
|
||||
lsn_after_append = []
|
||||
for i in range(3):
|
||||
res = env.safekeepers[i].append_logical_message(
|
||||
tenant_id,
|
||||
timeline_id,
|
||||
{
|
||||
"lm_prefix": "prefix",
|
||||
"lm_message": "message",
|
||||
"set_commit_lsn": True,
|
||||
"send_proposer_elected": True,
|
||||
"term": 2,
|
||||
"begin_lsn": begin_lsn,
|
||||
"epoch_start_lsn": epoch_start_lsn,
|
||||
"truncate_lsn": epoch_start_lsn,
|
||||
},
|
||||
)
|
||||
lsn_hex = lsn_to_hex(res["inserted_wal"]["end_lsn"])
|
||||
lsn_after_append.append(lsn_hex)
|
||||
log.info(f"safekeeper[{i}] lsn after append: {lsn_hex}")
|
||||
|
||||
# run sync safekeepers
|
||||
lsn_after_sync = pg.sync_safekeepers()
|
||||
log.info(f"lsn after sync = {lsn_after_sync}")
|
||||
|
||||
assert all(lsn_after_sync == lsn for lsn in lsn_after_append)
|
||||
|
||||
|
||||
def test_timeline_status(zenith_env_builder: ZenithEnvBuilder):
|
||||
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_timeline_status", "main")
|
||||
pg = env.postgres.create_start('test_timeline_status')
|
||||
|
||||
wa = env.safekeepers[0]
|
||||
wa_http_cli = wa.http_client()
|
||||
wa_http_cli.check_status()
|
||||
|
||||
# learn zenith timeline from compute
|
||||
tenant_id = pg.safe_psql("show zenith.zenith_tenant")[0][0]
|
||||
timeline_id = pg.safe_psql("show zenith.zenith_timeline")[0][0]
|
||||
|
||||
# fetch something sensible from status
|
||||
epoch = wa_http_cli.timeline_status(tenant_id, timeline_id).acceptor_epoch
|
||||
|
||||
pg.safe_psql("create table t(i int)")
|
||||
|
||||
# ensure epoch goes up after reboot
|
||||
pg.stop().start()
|
||||
pg.safe_psql("insert into t values(10)")
|
||||
|
||||
epoch_after_reboot = wa_http_cli.timeline_status(tenant_id, timeline_id).acceptor_epoch
|
||||
assert epoch_after_reboot > epoch
|
||||
|
||||
|
||||
class SafekeeperEnv:
|
||||
def __init__(self,
|
||||
repo_dir: Path,
|
||||
port_distributor: PortDistributor,
|
||||
pg_bin: PgBin,
|
||||
num_safekeepers: int = 1):
|
||||
self.repo_dir = repo_dir
|
||||
self.port_distributor = port_distributor
|
||||
self.pg_bin = pg_bin
|
||||
self.num_safekeepers = num_safekeepers
|
||||
self.bin_safekeeper = os.path.join(str(zenith_binpath), 'safekeeper')
|
||||
self.safekeepers: Optional[List[subprocess.CompletedProcess[Any]]] = None
|
||||
self.postgres: Optional[ProposerPostgres] = None
|
||||
self.tenant_id: Optional[uuid.UUID] = None
|
||||
self.timeline_id: Optional[uuid.UUID] = None
|
||||
|
||||
def init(self) -> "SafekeeperEnv":
|
||||
assert self.postgres is None, "postgres is already initialized"
|
||||
assert self.safekeepers is None, "safekeepers are already initialized"
|
||||
|
||||
self.timeline_id = uuid.uuid4()
|
||||
self.tenant_id = uuid.uuid4()
|
||||
mkdir_if_needed(str(self.repo_dir))
|
||||
|
||||
# Create config and a Safekeeper object for each safekeeper
|
||||
self.safekeepers = []
|
||||
for i in range(1, self.num_safekeepers + 1):
|
||||
self.safekeepers.append(self.start_safekeeper(i))
|
||||
|
||||
# Create and start postgres
|
||||
self.postgres = self.create_postgres()
|
||||
self.postgres.start()
|
||||
|
||||
return self
|
||||
|
||||
def start_safekeeper(self, i):
|
||||
port = SafekeeperPort(
|
||||
pg=self.port_distributor.get_port(),
|
||||
http=self.port_distributor.get_port(),
|
||||
)
|
||||
|
||||
if self.num_safekeepers == 1:
|
||||
name = "single"
|
||||
else:
|
||||
name = f"sk{i}"
|
||||
|
||||
safekeeper_dir = os.path.join(self.repo_dir, name)
|
||||
mkdir_if_needed(safekeeper_dir)
|
||||
|
||||
args = [
|
||||
self.bin_safekeeper,
|
||||
"-l",
|
||||
f"127.0.0.1:{port.pg}",
|
||||
"--listen-http",
|
||||
f"127.0.0.1:{port.http}",
|
||||
"-D",
|
||||
safekeeper_dir,
|
||||
"--daemonize"
|
||||
]
|
||||
|
||||
log.info(f'Running command "{" ".join(args)}"')
|
||||
return subprocess.run(args, check=True)
|
||||
|
||||
def get_safekeeper_connstrs(self):
|
||||
return ','.join([sk_proc.args[2] for sk_proc in self.safekeepers])
|
||||
|
||||
def create_postgres(self):
|
||||
pgdata_dir = os.path.join(self.repo_dir, "proposer_pgdata")
|
||||
pg = ProposerPostgres(pgdata_dir,
|
||||
self.pg_bin,
|
||||
self.timeline_id,
|
||||
self.tenant_id,
|
||||
"127.0.0.1",
|
||||
self.port_distributor.get_port())
|
||||
pg.initdb()
|
||||
pg.create_dir_config(self.get_safekeeper_connstrs())
|
||||
return pg
|
||||
|
||||
def kill_safekeeper(self, sk_dir):
|
||||
"""Read pid file and kill process"""
|
||||
pid_file = os.path.join(sk_dir, "safekeeper.pid")
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read())
|
||||
log.info(f"Killing safekeeper with pid {pid}")
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
log.info('Cleaning up all safekeeper and compute nodes')
|
||||
|
||||
# Stop all the nodes
|
||||
if self.postgres is not None:
|
||||
self.postgres.stop()
|
||||
if self.safekeepers is not None:
|
||||
for sk_proc in self.safekeepers:
|
||||
self.kill_safekeeper(sk_proc.args[6])
|
||||
|
||||
|
||||
def test_safekeeper_without_pageserver(test_output_dir: str,
|
||||
port_distributor: PortDistributor,
|
||||
pg_bin: PgBin):
|
||||
# Create the environment in the test-specific output dir
|
||||
repo_dir = Path(os.path.join(test_output_dir, "repo"))
|
||||
|
||||
env = SafekeeperEnv(
|
||||
repo_dir,
|
||||
port_distributor,
|
||||
pg_bin,
|
||||
num_safekeepers=1,
|
||||
)
|
||||
|
||||
with env:
|
||||
env.init()
|
||||
assert env.postgres is not None
|
||||
|
||||
env.postgres.safe_psql("create table t(i int)")
|
||||
env.postgres.safe_psql("insert into t select generate_series(1, 100)")
|
||||
res = env.postgres.safe_psql("select sum(i) from t")[0][0]
|
||||
assert res == 5050
|
||||
|
||||
|
||||
def test_replace_safekeeper(zenith_env_builder: ZenithEnvBuilder):
|
||||
def safekeepers_guc(env: ZenithEnv, sk_names: List[str]) -> str:
|
||||
return ','.join(
|
||||
[f'localhost:{sk.port.pg}' for sk in env.safekeepers if sk.name in sk_names])
|
||||
|
||||
def execute_payload(pg: Postgres):
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
cur.execute('CREATE TABLE IF NOT EXISTS t(key int, value text)')
|
||||
cur.execute("INSERT INTO t VALUES (0, 'something')")
|
||||
cur.execute('SELECT SUM(key) FROM t')
|
||||
sum_before = cur.fetchone()[0]
|
||||
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
cur.execute('SELECT SUM(key) FROM t')
|
||||
sum_after = cur.fetchone()[0]
|
||||
assert sum_after == sum_before + 5000050000
|
||||
|
||||
def show_statuses(safekeepers: List[Safekeeper], tenant_id: str, timeline_id: str):
|
||||
for sk in safekeepers:
|
||||
http_cli = sk.http_client()
|
||||
try:
|
||||
status = http_cli.timeline_status(tenant_id, timeline_id)
|
||||
log.info(f"Safekeeper {sk.name} status: {status}")
|
||||
except Exception as e:
|
||||
log.info(f"Safekeeper {sk.name} status error: {e}")
|
||||
|
||||
zenith_env_builder.num_safekeepers = 4
|
||||
env = zenith_env_builder.init()
|
||||
env.zenith_cli.create_branch("test_replace_safekeeper", "main")
|
||||
|
||||
log.info("Use only first 3 safekeepers")
|
||||
env.safekeepers[3].stop()
|
||||
active_safekeepers = ['sk1', 'sk2', 'sk3']
|
||||
pg = env.postgres.create('test_replace_safekeeper')
|
||||
pg.adjust_for_wal_acceptors(safekeepers_guc(env, active_safekeepers))
|
||||
pg.start()
|
||||
|
||||
# learn zenith timeline from compute
|
||||
tenant_id = pg.safe_psql("show zenith.zenith_tenant")[0][0]
|
||||
timeline_id = pg.safe_psql("show zenith.zenith_timeline")[0][0]
|
||||
|
||||
execute_payload(pg)
|
||||
show_statuses(env.safekeepers, tenant_id, timeline_id)
|
||||
|
||||
log.info("Restart all safekeepers to flush everything")
|
||||
env.safekeepers[0].stop(immediate=True)
|
||||
execute_payload(pg)
|
||||
env.safekeepers[0].start()
|
||||
env.safekeepers[1].stop(immediate=True)
|
||||
execute_payload(pg)
|
||||
env.safekeepers[1].start()
|
||||
env.safekeepers[2].stop(immediate=True)
|
||||
execute_payload(pg)
|
||||
env.safekeepers[2].start()
|
||||
|
||||
env.safekeepers[0].stop(immediate=True)
|
||||
env.safekeepers[1].stop(immediate=True)
|
||||
env.safekeepers[2].stop(immediate=True)
|
||||
env.safekeepers[0].start()
|
||||
env.safekeepers[1].start()
|
||||
env.safekeepers[2].start()
|
||||
|
||||
execute_payload(pg)
|
||||
show_statuses(env.safekeepers, tenant_id, timeline_id)
|
||||
|
||||
log.info("Stop sk1 (simulate failure) and use only quorum of sk2 and sk3")
|
||||
env.safekeepers[0].stop(immediate=True)
|
||||
execute_payload(pg)
|
||||
show_statuses(env.safekeepers, tenant_id, timeline_id)
|
||||
|
||||
log.info("Recreate postgres to replace failed sk1 with new sk4")
|
||||
pg.stop_and_destroy().create('test_replace_safekeeper')
|
||||
active_safekeepers = ['sk2', 'sk3', 'sk4']
|
||||
env.safekeepers[3].start()
|
||||
pg.adjust_for_wal_acceptors(safekeepers_guc(env, active_safekeepers))
|
||||
pg.start()
|
||||
|
||||
execute_payload(pg)
|
||||
show_statuses(env.safekeepers, tenant_id, timeline_id)
|
||||
|
||||
log.info("Stop sk2 to require quorum of sk3 and sk4 for normal work")
|
||||
env.safekeepers[1].stop(immediate=True)
|
||||
execute_payload(pg)
|
||||
show_statuses(env.safekeepers, tenant_id, timeline_id)
|
||||
@@ -1,211 +0,0 @@
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import random
|
||||
import time
|
||||
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder, Postgres, Safekeeper
|
||||
from fixtures.log_helper import getLogger
|
||||
from fixtures.utils import lsn_from_hex, lsn_to_hex
|
||||
from typing import List
|
||||
|
||||
log = getLogger('root.wal_acceptor_async')
|
||||
|
||||
|
||||
class BankClient(object):
|
||||
def __init__(self, conn: asyncpg.Connection, n_accounts, init_amount):
|
||||
self.conn: asyncpg.Connection = conn
|
||||
self.n_accounts = n_accounts
|
||||
self.init_amount = init_amount
|
||||
|
||||
async def initdb(self):
|
||||
await self.conn.execute('DROP TABLE IF EXISTS bank_accs')
|
||||
await self.conn.execute('CREATE TABLE bank_accs(uid int primary key, amount int)')
|
||||
await self.conn.execute(
|
||||
'''
|
||||
INSERT INTO bank_accs
|
||||
SELECT *, $1 FROM generate_series(0, $2)
|
||||
''',
|
||||
self.init_amount,
|
||||
self.n_accounts - 1)
|
||||
await self.conn.execute('DROP TABLE IF EXISTS bank_log')
|
||||
await self.conn.execute('CREATE TABLE bank_log(from_uid int, to_uid int, amount int)')
|
||||
|
||||
# TODO: Remove when https://github.com/zenithdb/zenith/issues/644 is fixed
|
||||
await self.conn.execute('ALTER TABLE bank_accs SET (autovacuum_enabled = false)')
|
||||
await self.conn.execute('ALTER TABLE bank_log SET (autovacuum_enabled = false)')
|
||||
|
||||
async def check_invariant(self):
|
||||
row = await self.conn.fetchrow('SELECT sum(amount) AS sum FROM bank_accs')
|
||||
assert row['sum'] == self.n_accounts * self.init_amount
|
||||
|
||||
|
||||
async def bank_transfer(conn: asyncpg.Connection, from_uid, to_uid, amount):
|
||||
# avoid deadlocks by sorting uids
|
||||
if from_uid > to_uid:
|
||||
from_uid, to_uid, amount = to_uid, from_uid, -amount
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
'UPDATE bank_accs SET amount = amount + ($1) WHERE uid = $2',
|
||||
amount,
|
||||
to_uid,
|
||||
)
|
||||
await conn.execute(
|
||||
'UPDATE bank_accs SET amount = amount - ($1) WHERE uid = $2',
|
||||
amount,
|
||||
from_uid,
|
||||
)
|
||||
await conn.execute(
|
||||
'INSERT INTO bank_log VALUES ($1, $2, $3)',
|
||||
from_uid,
|
||||
to_uid,
|
||||
amount,
|
||||
)
|
||||
|
||||
|
||||
class WorkerStats(object):
|
||||
def __init__(self, n_workers):
|
||||
self.counters = [0] * n_workers
|
||||
self.running = True
|
||||
|
||||
def reset(self):
|
||||
self.counters = [0] * len(self.counters)
|
||||
|
||||
def inc_progress(self, worker_id):
|
||||
self.counters[worker_id] += 1
|
||||
|
||||
def check_progress(self):
|
||||
log.debug("Workers progress: {}".format(self.counters))
|
||||
|
||||
# every worker should finish at least one tx
|
||||
assert all(cnt > 0 for cnt in self.counters)
|
||||
|
||||
progress = sum(self.counters)
|
||||
log.info('All workers made {} transactions'.format(progress))
|
||||
|
||||
|
||||
async def run_random_worker(stats: WorkerStats, pg: Postgres, worker_id, n_accounts, max_transfer):
|
||||
pg_conn = await pg.connect_async()
|
||||
log.debug('Started worker {}'.format(worker_id))
|
||||
|
||||
while stats.running:
|
||||
from_uid = random.randint(0, n_accounts - 1)
|
||||
to_uid = (from_uid + random.randint(1, n_accounts - 1)) % n_accounts
|
||||
amount = random.randint(1, max_transfer)
|
||||
|
||||
await bank_transfer(pg_conn, from_uid, to_uid, amount)
|
||||
stats.inc_progress(worker_id)
|
||||
|
||||
log.debug('Executed transfer({}) {} => {}'.format(amount, from_uid, to_uid))
|
||||
|
||||
log.debug('Finished worker {}'.format(worker_id))
|
||||
|
||||
await pg_conn.close()
|
||||
|
||||
|
||||
async def wait_for_lsn(safekeeper: Safekeeper,
|
||||
tenant_id: str,
|
||||
timeline_id: str,
|
||||
wait_lsn: str,
|
||||
polling_interval=1,
|
||||
timeout=60):
|
||||
"""
|
||||
Poll flush_lsn from safekeeper until it's greater or equal than
|
||||
provided wait_lsn. To do that, timeline_status is fetched from
|
||||
safekeeper every polling_interval seconds.
|
||||
"""
|
||||
|
||||
started_at = time.time()
|
||||
client = safekeeper.http_client()
|
||||
|
||||
flush_lsn = client.timeline_status(tenant_id, timeline_id).flush_lsn
|
||||
log.info(
|
||||
f'Safekeeper at port {safekeeper.port.pg} has flush_lsn {flush_lsn}, waiting for lsn {wait_lsn}'
|
||||
)
|
||||
|
||||
while lsn_from_hex(wait_lsn) > lsn_from_hex(flush_lsn):
|
||||
elapsed = time.time() - started_at
|
||||
if elapsed > timeout:
|
||||
raise RuntimeError(
|
||||
f"timed out waiting for safekeeper at port {safekeeper.port.pg} to reach {wait_lsn}, current lsn is {flush_lsn}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(polling_interval)
|
||||
flush_lsn = client.timeline_status(tenant_id, timeline_id).flush_lsn
|
||||
log.debug(f'safekeeper port={safekeeper.port.pg} flush_lsn={flush_lsn} wait_lsn={wait_lsn}')
|
||||
|
||||
|
||||
# This test will run several iterations and check progress in each of them.
|
||||
# On each iteration 1 acceptor is stopped, and 2 others should allow
|
||||
# background workers execute transactions. In the end, state should remain
|
||||
# consistent.
|
||||
async def run_restarts_under_load(pg: Postgres, acceptors: List[Safekeeper], n_workers=10):
|
||||
n_accounts = 100
|
||||
init_amount = 100000
|
||||
max_transfer = 100
|
||||
period_time = 10
|
||||
iterations = 6
|
||||
|
||||
# Set timeout for this test at 5 minutes. It should be enough for test to complete
|
||||
# and less than CircleCI's no_output_timeout, taking into account that this timeout
|
||||
# is checked only at the beginning of every iteration.
|
||||
test_timeout_at = time.monotonic() + 5 * 60
|
||||
|
||||
pg_conn = await pg.connect_async()
|
||||
tenant_id = await pg_conn.fetchval("show zenith.zenith_tenant")
|
||||
timeline_id = await pg_conn.fetchval("show zenith.zenith_timeline")
|
||||
|
||||
bank = BankClient(pg_conn, n_accounts=n_accounts, init_amount=init_amount)
|
||||
# create tables and initial balances
|
||||
await bank.initdb()
|
||||
|
||||
stats = WorkerStats(n_workers)
|
||||
workers = []
|
||||
for worker_id in range(n_workers):
|
||||
worker = run_random_worker(stats, pg, worker_id, bank.n_accounts, max_transfer)
|
||||
workers.append(asyncio.create_task(worker))
|
||||
|
||||
for it in range(iterations):
|
||||
assert time.monotonic() < test_timeout_at, 'test timed out'
|
||||
|
||||
victim_idx = it % len(acceptors)
|
||||
victim = acceptors[victim_idx]
|
||||
victim.stop()
|
||||
|
||||
flush_lsn = await pg_conn.fetchval('SELECT pg_current_wal_flush_lsn()')
|
||||
flush_lsn = lsn_to_hex(flush_lsn)
|
||||
log.info(f'Postgres flush_lsn {flush_lsn}')
|
||||
|
||||
# Wait until alive safekeepers catch up with postgres
|
||||
for idx, safekeeper in enumerate(acceptors):
|
||||
if idx != victim_idx:
|
||||
await wait_for_lsn(safekeeper, tenant_id, timeline_id, flush_lsn)
|
||||
|
||||
stats.reset()
|
||||
await asyncio.sleep(period_time)
|
||||
# assert that at least one transaction has completed in every worker
|
||||
stats.check_progress()
|
||||
|
||||
victim.start()
|
||||
|
||||
log.info('Iterations are finished, exiting coroutines...')
|
||||
stats.running = False
|
||||
# await all workers
|
||||
await asyncio.gather(*workers)
|
||||
# assert balances sum hasn't changed
|
||||
await bank.check_invariant()
|
||||
await pg_conn.close()
|
||||
|
||||
|
||||
# restart acceptors one by one, while executing and validating bank transactions
|
||||
def test_restarts_under_load(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
env.zenith_cli.create_branch("test_wal_acceptors_restarts_under_load", "main")
|
||||
pg = env.postgres.create_start('test_wal_acceptors_restarts_under_load')
|
||||
|
||||
asyncio.run(run_restarts_under_load(pg, env.safekeepers))
|
||||
|
||||
# TODO: Remove when https://github.com/zenithdb/zenith/issues/644 is fixed
|
||||
pg.stop()
|
||||
@@ -1,129 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
from psycopg2.extensions import cursor as PgCursor
|
||||
from fixtures.zenith_fixtures import ZenithEnv, ZenithEnvBuilder, ZenithPageserverHttpClient
|
||||
from typing import cast
|
||||
|
||||
|
||||
def helper_compare_branch_list(pageserver_http_client: ZenithPageserverHttpClient,
|
||||
env: ZenithEnv,
|
||||
initial_tenant: uuid.UUID):
|
||||
"""
|
||||
Compare branches list returned by CLI and directly via API.
|
||||
Filters out branches created by other tests.
|
||||
"""
|
||||
branches = pageserver_http_client.branch_list(initial_tenant)
|
||||
branches_api = sorted(map(lambda b: cast(str, b['name']), branches))
|
||||
branches_api = [b for b in branches_api if b.startswith('test_cli_') or b in ('empty', 'main')]
|
||||
|
||||
res = env.zenith_cli.list_branches()
|
||||
branches_cli = sorted(map(lambda b: b.split(':')[-1].strip(), res.stdout.strip().split("\n")))
|
||||
branches_cli = [b for b in branches_cli if b.startswith('test_cli_') or b in ('empty', 'main')]
|
||||
|
||||
res = env.zenith_cli.list_branches(tenant_id=initial_tenant)
|
||||
branches_cli_with_tenant_arg = sorted(
|
||||
map(lambda b: b.split(':')[-1].strip(), res.stdout.strip().split("\n")))
|
||||
branches_cli_with_tenant_arg = [
|
||||
b for b in branches_cli if b.startswith('test_cli_') or b in ('empty', 'main')
|
||||
]
|
||||
|
||||
assert branches_api == branches_cli == branches_cli_with_tenant_arg
|
||||
|
||||
|
||||
def test_cli_branch_list(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
pageserver_http_client = env.pageserver.http_client()
|
||||
|
||||
# Initial sanity check
|
||||
helper_compare_branch_list(pageserver_http_client, env, env.initial_tenant)
|
||||
env.zenith_cli.create_branch("test_cli_branch_list_main", "empty")
|
||||
helper_compare_branch_list(pageserver_http_client, env, env.initial_tenant)
|
||||
|
||||
# Create a nested branch
|
||||
res = env.zenith_cli.create_branch("test_cli_branch_list_nested", "test_cli_branch_list_main")
|
||||
assert res.stderr == ''
|
||||
helper_compare_branch_list(pageserver_http_client, env, env.initial_tenant)
|
||||
|
||||
# Check that all new branches are visible via CLI
|
||||
res = env.zenith_cli.list_branches()
|
||||
assert res.stderr == ''
|
||||
branches_cli = sorted(map(lambda b: b.split(':')[-1].strip(), res.stdout.strip().split("\n")))
|
||||
|
||||
assert 'test_cli_branch_list_main' in branches_cli
|
||||
assert 'test_cli_branch_list_nested' in branches_cli
|
||||
|
||||
|
||||
def helper_compare_tenant_list(pageserver_http_client: ZenithPageserverHttpClient, env: ZenithEnv):
|
||||
tenants = pageserver_http_client.tenant_list()
|
||||
tenants_api = sorted(map(lambda t: cast(str, t['id']), tenants))
|
||||
|
||||
res = env.zenith_cli.list_tenants()
|
||||
assert res.stderr == ''
|
||||
tenants_cli = sorted(map(lambda t: t.split()[0], res.stdout.splitlines()))
|
||||
|
||||
assert tenants_api == tenants_cli
|
||||
|
||||
|
||||
def test_cli_tenant_list(zenith_simple_env: ZenithEnv):
|
||||
env = zenith_simple_env
|
||||
pageserver_http_client = env.pageserver.http_client()
|
||||
# Initial sanity check
|
||||
helper_compare_tenant_list(pageserver_http_client, env)
|
||||
|
||||
# Create new tenant
|
||||
tenant1 = uuid.uuid4()
|
||||
env.zenith_cli.create_tenant(tenant1)
|
||||
|
||||
# check tenant1 appeared
|
||||
helper_compare_tenant_list(pageserver_http_client, env)
|
||||
|
||||
# Create new tenant
|
||||
tenant2 = uuid.uuid4()
|
||||
env.zenith_cli.create_tenant(tenant2)
|
||||
|
||||
# check tenant2 appeared
|
||||
helper_compare_tenant_list(pageserver_http_client, env)
|
||||
|
||||
res = env.zenith_cli.list_tenants()
|
||||
tenants = sorted(map(lambda t: t.split()[0], res.stdout.splitlines()))
|
||||
|
||||
assert env.initial_tenant.hex in tenants
|
||||
assert tenant1.hex in tenants
|
||||
assert tenant2.hex in tenants
|
||||
|
||||
|
||||
def test_cli_ipv4_listeners(zenith_env_builder: ZenithEnvBuilder):
|
||||
# Start with single sk
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
# Connect to sk port on v4 loopback
|
||||
res = requests.get(f'http://127.0.0.1:{env.safekeepers[0].port.http}/v1/status')
|
||||
assert res.ok
|
||||
|
||||
# FIXME Test setup is using localhost:xx in ps config.
|
||||
# Perhaps consider switching test suite to v4 loopback.
|
||||
|
||||
# Connect to ps port on v4 loopback
|
||||
# res = requests.get(f'http://127.0.0.1:{env.pageserver.service_port.http}/v1/status')
|
||||
# assert res.ok
|
||||
|
||||
|
||||
def test_cli_start_stop(zenith_env_builder: ZenithEnvBuilder):
|
||||
# Start with single sk
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
|
||||
# Stop default ps/sk
|
||||
env.zenith_cli.pageserver_stop()
|
||||
env.zenith_cli.safekeeper_stop()
|
||||
|
||||
# Default start
|
||||
res = env.zenith_cli.raw_cli(["start"])
|
||||
res.check_returncode()
|
||||
|
||||
# Default stop
|
||||
res = env.zenith_cli.raw_cli(["stop"])
|
||||
res.check_returncode()
|
||||
@@ -1,47 +0,0 @@
|
||||
import os
|
||||
|
||||
from fixtures.utils import mkdir_if_needed
|
||||
from fixtures.zenith_fixtures import ZenithEnv, base_dir, pg_distrib_dir
|
||||
|
||||
|
||||
def test_isolation(zenith_simple_env: ZenithEnv, test_output_dir, pg_bin, capsys):
|
||||
env = zenith_simple_env
|
||||
|
||||
env.zenith_cli.create_branch("test_isolation", "empty")
|
||||
# Connect to postgres and create a database called "regression".
|
||||
# isolation tests use prepared transactions, so enable them
|
||||
pg = env.postgres.create_start('test_isolation', config_lines=['max_prepared_transactions=100'])
|
||||
pg.safe_psql('CREATE DATABASE isolation_regression')
|
||||
|
||||
# Create some local directories for pg_isolation_regress to run in.
|
||||
runpath = os.path.join(test_output_dir, 'regress')
|
||||
mkdir_if_needed(runpath)
|
||||
mkdir_if_needed(os.path.join(runpath, 'testtablespace'))
|
||||
|
||||
# Compute all the file locations that pg_isolation_regress will need.
|
||||
build_path = os.path.join(pg_distrib_dir, 'build/src/test/isolation')
|
||||
src_path = os.path.join(base_dir, 'vendor/postgres/src/test/isolation')
|
||||
bindir = os.path.join(pg_distrib_dir, 'bin')
|
||||
schedule = os.path.join(src_path, 'isolation_schedule')
|
||||
pg_isolation_regress = os.path.join(build_path, 'pg_isolation_regress')
|
||||
|
||||
pg_isolation_regress_command = [
|
||||
pg_isolation_regress,
|
||||
'--use-existing',
|
||||
'--bindir={}'.format(bindir),
|
||||
'--dlpath={}'.format(build_path),
|
||||
'--inputdir={}'.format(src_path),
|
||||
'--schedule={}'.format(schedule),
|
||||
]
|
||||
|
||||
env_vars = {
|
||||
'PGPORT': str(pg.port),
|
||||
'PGUSER': pg.username,
|
||||
'PGHOST': pg.host,
|
||||
}
|
||||
|
||||
# Run the command.
|
||||
# We don't capture the output. It's not too chatty, and it always
|
||||
# logs the exact same data to `regression.out` anyway.
|
||||
with capsys.disabled():
|
||||
pg_bin.run(pg_isolation_regress_command, env=env_vars, cwd=runpath)
|
||||
@@ -1,54 +0,0 @@
|
||||
import os
|
||||
|
||||
from fixtures.utils import mkdir_if_needed
|
||||
from fixtures.zenith_fixtures import ZenithEnv, check_restored_datadir_content, base_dir, pg_distrib_dir
|
||||
|
||||
|
||||
def test_pg_regress(zenith_simple_env: ZenithEnv, test_output_dir: str, pg_bin, capsys):
|
||||
env = zenith_simple_env
|
||||
|
||||
env.zenith_cli.create_branch("test_pg_regress", "empty")
|
||||
# Connect to postgres and create a database called "regression".
|
||||
pg = env.postgres.create_start('test_pg_regress')
|
||||
pg.safe_psql('CREATE DATABASE regression')
|
||||
|
||||
# Create some local directories for pg_regress to run in.
|
||||
runpath = os.path.join(test_output_dir, 'regress')
|
||||
mkdir_if_needed(runpath)
|
||||
mkdir_if_needed(os.path.join(runpath, 'testtablespace'))
|
||||
|
||||
# Compute all the file locations that pg_regress will need.
|
||||
build_path = os.path.join(pg_distrib_dir, 'build/src/test/regress')
|
||||
src_path = os.path.join(base_dir, 'vendor/postgres/src/test/regress')
|
||||
bindir = os.path.join(pg_distrib_dir, 'bin')
|
||||
schedule = os.path.join(src_path, 'parallel_schedule')
|
||||
pg_regress = os.path.join(build_path, 'pg_regress')
|
||||
|
||||
pg_regress_command = [
|
||||
pg_regress,
|
||||
'--bindir=""',
|
||||
'--use-existing',
|
||||
'--bindir={}'.format(bindir),
|
||||
'--dlpath={}'.format(build_path),
|
||||
'--schedule={}'.format(schedule),
|
||||
'--inputdir={}'.format(src_path),
|
||||
]
|
||||
|
||||
env_vars = {
|
||||
'PGPORT': str(pg.port),
|
||||
'PGUSER': pg.username,
|
||||
'PGHOST': pg.host,
|
||||
}
|
||||
|
||||
# Run the command.
|
||||
# We don't capture the output. It's not too chatty, and it always
|
||||
# logs the exact same data to `regression.out` anyway.
|
||||
with capsys.disabled():
|
||||
pg_bin.run(pg_regress_command, env=env_vars, cwd=runpath)
|
||||
|
||||
# checkpoint one more time to ensure that the lsn we get is the latest one
|
||||
pg.safe_psql('CHECKPOINT')
|
||||
lsn = pg.safe_psql('select pg_current_wal_insert_lsn()')[0][0]
|
||||
|
||||
# Check that we restore the content of the datadir correctly
|
||||
check_restored_datadir_content(test_output_dir, env, pg)
|
||||
@@ -1,59 +0,0 @@
|
||||
import os
|
||||
|
||||
from fixtures.utils import mkdir_if_needed
|
||||
from fixtures.zenith_fixtures import (ZenithEnv,
|
||||
check_restored_datadir_content,
|
||||
base_dir,
|
||||
pg_distrib_dir)
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
def test_zenith_regress(zenith_simple_env: ZenithEnv, test_output_dir, pg_bin, capsys):
|
||||
env = zenith_simple_env
|
||||
|
||||
env.zenith_cli.create_branch("test_zenith_regress", "empty")
|
||||
# Connect to postgres and create a database called "regression".
|
||||
pg = env.postgres.create_start('test_zenith_regress')
|
||||
pg.safe_psql('CREATE DATABASE regression')
|
||||
|
||||
# Create some local directories for pg_regress to run in.
|
||||
runpath = os.path.join(test_output_dir, 'regress')
|
||||
mkdir_if_needed(runpath)
|
||||
mkdir_if_needed(os.path.join(runpath, 'testtablespace'))
|
||||
|
||||
# Compute all the file locations that pg_regress will need.
|
||||
# This test runs zenith specific tests
|
||||
build_path = os.path.join(pg_distrib_dir, 'build/src/test/regress')
|
||||
src_path = os.path.join(base_dir, 'test_runner/zenith_regress')
|
||||
bindir = os.path.join(pg_distrib_dir, 'bin')
|
||||
schedule = os.path.join(src_path, 'parallel_schedule')
|
||||
pg_regress = os.path.join(build_path, 'pg_regress')
|
||||
|
||||
pg_regress_command = [
|
||||
pg_regress,
|
||||
'--use-existing',
|
||||
'--bindir={}'.format(bindir),
|
||||
'--dlpath={}'.format(build_path),
|
||||
'--schedule={}'.format(schedule),
|
||||
'--inputdir={}'.format(src_path),
|
||||
]
|
||||
|
||||
log.info(pg_regress_command)
|
||||
env_vars = {
|
||||
'PGPORT': str(pg.port),
|
||||
'PGUSER': pg.username,
|
||||
'PGHOST': pg.host,
|
||||
}
|
||||
|
||||
# Run the command.
|
||||
# We don't capture the output. It's not too chatty, and it always
|
||||
# logs the exact same data to `regression.out` anyway.
|
||||
with capsys.disabled():
|
||||
pg_bin.run(pg_regress_command, env=env_vars, cwd=runpath)
|
||||
|
||||
# checkpoint one more time to ensure that the lsn we get is the latest one
|
||||
pg.safe_psql('CHECKPOINT')
|
||||
lsn = pg.safe_psql('select pg_current_wal_insert_lsn()')[0][0]
|
||||
|
||||
# Check that we restore the content of the datadir correctly
|
||||
check_restored_datadir_content(test_output_dir, env, pg)
|
||||
@@ -1,6 +1,7 @@
|
||||
pytest_plugins = (
|
||||
"fixtures.zenith_fixtures",
|
||||
"fixtures.neon_fixtures",
|
||||
"fixtures.benchmark_fixture",
|
||||
"fixtures.pg_stats",
|
||||
"fixtures.compare_fixtures",
|
||||
"fixtures.slow",
|
||||
)
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
import calendar
|
||||
import dataclasses
|
||||
import enum
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import timeit
|
||||
import calendar
|
||||
import enum
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import pytest
|
||||
from _pytest.config import Config
|
||||
from _pytest.terminal import TerminalReporter
|
||||
import warnings
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Type-related stuff
|
||||
from typing import Iterator
|
||||
from typing import Callable, ClassVar, Iterator, Optional
|
||||
|
||||
import pytest
|
||||
from _pytest.config import Config
|
||||
from _pytest.config.argparsing import Parser
|
||||
from _pytest.terminal import TerminalReporter
|
||||
from fixtures.neon_fixtures import NeonPageserver
|
||||
from fixtures.types import TenantId, TimelineId
|
||||
|
||||
"""
|
||||
This file contains fixtures for micro-benchmarks.
|
||||
|
||||
To use, declare the 'zenbenchmark' fixture in the test function. Run the
|
||||
bencmark, and then record the result by calling zenbenchmark.record. For example:
|
||||
To use, declare the `zenbenchmark` fixture in the test function. Run the
|
||||
bencmark, and then record the result by calling `zenbenchmark.record`. For example:
|
||||
|
||||
import timeit
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
|
||||
def test_mybench(zenith_simple_env: env, zenbenchmark):
|
||||
|
||||
# Initialize the test
|
||||
...
|
||||
|
||||
# Run the test, timing how long it takes
|
||||
with zenbenchmark.record_duration('test_query'):
|
||||
cur.execute('SELECT test_query(...)')
|
||||
|
||||
# Record another measurement
|
||||
zenbenchmark.record('speed_of_light', 300000, 'km/s')
|
||||
>>> import timeit
|
||||
>>> from fixtures.neon_fixtures import NeonEnv
|
||||
>>> def test_mybench(neon_simple_env: NeonEnv, zenbenchmark):
|
||||
... # Initialize the test
|
||||
... ...
|
||||
... # Run the test, timing how long it takes
|
||||
... with zenbenchmark.record_duration('test_query'):
|
||||
... cur.execute('SELECT test_query(...)')
|
||||
... # Record another measurement
|
||||
... zenbenchmark.record('speed_of_light', 300000, 'km/s')
|
||||
|
||||
There's no need to import this file to use it. It should be declared as a plugin
|
||||
inside conftest.py, and that makes it available to all tests.
|
||||
inside `conftest.py`, and that makes it available to all tests.
|
||||
|
||||
You can measure multiple things in one test, and record each one with a separate
|
||||
call to zenbenchmark. For example, you could time the bulk loading that happens
|
||||
call to `zenbenchmark`. For example, you could time the bulk loading that happens
|
||||
in the test initialization, or measure disk usage after the test query.
|
||||
|
||||
"""
|
||||
@@ -51,77 +49,143 @@ in the test initialization, or measure disk usage after the test query.
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PgBenchRunResult:
|
||||
scale: int
|
||||
number_of_clients: int
|
||||
number_of_threads: int
|
||||
number_of_transactions_actually_processed: int
|
||||
latency_average: float
|
||||
latency_stddev: float
|
||||
tps_including_connection_time: float
|
||||
tps_excluding_connection_time: float
|
||||
init_duration: float
|
||||
init_start_timestamp: int
|
||||
init_end_timestamp: int
|
||||
latency_stddev: Optional[float]
|
||||
tps: float
|
||||
run_duration: float
|
||||
run_start_timestamp: int
|
||||
run_end_timestamp: int
|
||||
scale: int
|
||||
|
||||
# TODO progress
|
||||
|
||||
@classmethod
|
||||
def parse_from_output(
|
||||
def parse_from_stdout(
|
||||
cls,
|
||||
out: 'subprocess.CompletedProcess[str]',
|
||||
init_duration: float,
|
||||
init_start_timestamp: int,
|
||||
init_end_timestamp: int,
|
||||
stdout: str,
|
||||
run_duration: float,
|
||||
run_start_timestamp: int,
|
||||
run_end_timestamp: int,
|
||||
):
|
||||
stdout_lines = out.stdout.splitlines()
|
||||
stdout_lines = stdout.splitlines()
|
||||
|
||||
latency_stddev = None
|
||||
|
||||
# we know significant parts of these values from test input
|
||||
# but to be precise take them from output
|
||||
# scaling factor: 5
|
||||
assert "scaling factor" in stdout_lines[1]
|
||||
scale = int(stdout_lines[1].split()[-1])
|
||||
# number of clients: 1
|
||||
assert "number of clients" in stdout_lines[3]
|
||||
number_of_clients = int(stdout_lines[3].split()[-1])
|
||||
# number of threads: 1
|
||||
assert "number of threads" in stdout_lines[4]
|
||||
number_of_threads = int(stdout_lines[4].split()[-1])
|
||||
# number of transactions actually processed: 1000/1000
|
||||
assert "number of transactions actually processed" in stdout_lines[6]
|
||||
number_of_transactions_actually_processed = int(stdout_lines[6].split("/")[1])
|
||||
# latency average = 19.894 ms
|
||||
assert "latency average" in stdout_lines[7]
|
||||
latency_average = stdout_lines[7].split()[-2]
|
||||
# latency stddev = 3.387 ms
|
||||
assert "latency stddev" in stdout_lines[8]
|
||||
latency_stddev = stdout_lines[8].split()[-2]
|
||||
# tps = 50.219689 (including connections establishing)
|
||||
assert "(including connections establishing)" in stdout_lines[9]
|
||||
tps_including_connection_time = stdout_lines[9].split()[2]
|
||||
# tps = 50.264435 (excluding connections establishing)
|
||||
assert "(excluding connections establishing)" in stdout_lines[10]
|
||||
tps_excluding_connection_time = stdout_lines[10].split()[2]
|
||||
for line in stdout_lines:
|
||||
# scaling factor: 5
|
||||
if line.startswith("scaling factor:"):
|
||||
scale = int(line.split()[-1])
|
||||
# number of clients: 1
|
||||
if line.startswith("number of clients: "):
|
||||
number_of_clients = int(line.split()[-1])
|
||||
# number of threads: 1
|
||||
if line.startswith("number of threads: "):
|
||||
number_of_threads = int(line.split()[-1])
|
||||
# number of transactions actually processed: 1000/1000
|
||||
# OR
|
||||
# number of transactions actually processed: 1000
|
||||
if line.startswith("number of transactions actually processed"):
|
||||
if "/" in line:
|
||||
number_of_transactions_actually_processed = int(line.split("/")[1])
|
||||
else:
|
||||
number_of_transactions_actually_processed = int(line.split()[-1])
|
||||
# latency average = 19.894 ms
|
||||
if line.startswith("latency average"):
|
||||
latency_average = float(line.split()[-2])
|
||||
# latency stddev = 3.387 ms
|
||||
# (only printed with some options)
|
||||
if line.startswith("latency stddev"):
|
||||
latency_stddev = float(line.split()[-2])
|
||||
|
||||
# Get the TPS without initial connection time. The format
|
||||
# of the tps lines changed in pgbench v14, but we accept
|
||||
# either format:
|
||||
#
|
||||
# pgbench v13 and below:
|
||||
# tps = 50.219689 (including connections establishing)
|
||||
# tps = 50.264435 (excluding connections establishing)
|
||||
#
|
||||
# pgbench v14:
|
||||
# initial connection time = 3.858 ms
|
||||
# tps = 309.281539 (without initial connection time)
|
||||
if line.startswith("tps = ") and (
|
||||
"(excluding connections establishing)" in line
|
||||
or "(without initial connection time)" in line
|
||||
):
|
||||
tps = float(line.split()[2])
|
||||
|
||||
return cls(
|
||||
scale=scale,
|
||||
number_of_clients=number_of_clients,
|
||||
number_of_threads=number_of_threads,
|
||||
number_of_transactions_actually_processed=number_of_transactions_actually_processed,
|
||||
latency_average=float(latency_average),
|
||||
latency_stddev=float(latency_stddev),
|
||||
tps_including_connection_time=float(tps_including_connection_time),
|
||||
tps_excluding_connection_time=float(tps_excluding_connection_time),
|
||||
init_duration=init_duration,
|
||||
init_start_timestamp=init_start_timestamp,
|
||||
init_end_timestamp=init_end_timestamp,
|
||||
latency_average=latency_average,
|
||||
latency_stddev=latency_stddev,
|
||||
tps=tps,
|
||||
run_duration=run_duration,
|
||||
run_start_timestamp=run_start_timestamp,
|
||||
run_end_timestamp=run_end_timestamp,
|
||||
scale=scale,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PgBenchInitResult:
|
||||
REGEX: ClassVar[re.Pattern] = re.compile( # type: ignore[type-arg]
|
||||
r"done in (\d+\.\d+) s "
|
||||
r"\("
|
||||
r"(?:drop tables (\d+\.\d+) s)?(?:, )?"
|
||||
r"(?:create tables (\d+\.\d+) s)?(?:, )?"
|
||||
r"(?:client-side generate (\d+\.\d+) s)?(?:, )?"
|
||||
r"(?:vacuum (\d+\.\d+) s)?(?:, )?"
|
||||
r"(?:primary keys (\d+\.\d+) s)?(?:, )?"
|
||||
r"\)\."
|
||||
)
|
||||
|
||||
total: float
|
||||
drop_tables: Optional[float]
|
||||
create_tables: Optional[float]
|
||||
client_side_generate: Optional[float]
|
||||
vacuum: Optional[float]
|
||||
primary_keys: Optional[float]
|
||||
duration: float
|
||||
start_timestamp: int
|
||||
end_timestamp: int
|
||||
|
||||
@classmethod
|
||||
def parse_from_stderr(
|
||||
cls,
|
||||
stderr: str,
|
||||
duration: float,
|
||||
start_timestamp: int,
|
||||
end_timestamp: int,
|
||||
):
|
||||
# Parses pgbench initialize output for default initialization steps (dtgvp)
|
||||
# Example: done in 5.66 s (drop tables 0.05 s, create tables 0.31 s, client-side generate 2.01 s, vacuum 0.53 s, primary keys 0.38 s).
|
||||
|
||||
last_line = stderr.splitlines()[-1]
|
||||
|
||||
if (m := cls.REGEX.match(last_line)) is not None:
|
||||
total, drop_tables, create_tables, client_side_generate, vacuum, primary_keys = [
|
||||
float(v) for v in m.groups() if v is not None
|
||||
]
|
||||
else:
|
||||
raise RuntimeError(f"can't parse pgbench initialize results from `{last_line}`")
|
||||
|
||||
return cls(
|
||||
total=total,
|
||||
drop_tables=drop_tables,
|
||||
create_tables=create_tables,
|
||||
client_side_generate=client_side_generate,
|
||||
vacuum=vacuum,
|
||||
primary_keys=primary_keys,
|
||||
duration=duration,
|
||||
start_timestamp=start_timestamp,
|
||||
end_timestamp=end_timestamp,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,19 +193,20 @@ class PgBenchRunResult:
|
||||
class MetricReport(str, enum.Enum): # str is a hack to make it json serializable
|
||||
# this means that this is a constant test parameter
|
||||
# like number of transactions, or number of clients
|
||||
TEST_PARAM = 'test_param'
|
||||
TEST_PARAM = "test_param"
|
||||
# reporter can use it to mark test runs with higher values as improvements
|
||||
HIGHER_IS_BETTER = 'higher_is_better'
|
||||
HIGHER_IS_BETTER = "higher_is_better"
|
||||
# the same but for lower values
|
||||
LOWER_IS_BETTER = 'lower_is_better'
|
||||
LOWER_IS_BETTER = "lower_is_better"
|
||||
|
||||
|
||||
class ZenithBenchmarker:
|
||||
class NeonBenchmarker:
|
||||
"""
|
||||
An object for recording benchmark results. This is created for each test
|
||||
function by the zenbenchmark fixture
|
||||
"""
|
||||
def __init__(self, property_recorder):
|
||||
|
||||
def __init__(self, property_recorder: Callable[[str, object], None]):
|
||||
# property recorder here is a pytest fixture provided by junitxml module
|
||||
# https://docs.pytest.org/en/6.2.x/reference.html#pytest.junitxml.record_property
|
||||
self.property_recorder = property_recorder
|
||||
@@ -157,7 +222,7 @@ class ZenithBenchmarker:
|
||||
Record a benchmark result.
|
||||
"""
|
||||
# just to namespace the value
|
||||
name = f"zenith_benchmarker_{metric_name}"
|
||||
name = f"neon_benchmarker_{metric_name}"
|
||||
self.property_recorder(
|
||||
name,
|
||||
{
|
||||
@@ -169,7 +234,7 @@ class ZenithBenchmarker:
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def record_duration(self, metric_name: str):
|
||||
def record_duration(self, metric_name: str) -> Iterator[None]:
|
||||
"""
|
||||
Record a duration. Usage:
|
||||
|
||||
@@ -187,72 +252,105 @@ class ZenithBenchmarker:
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
|
||||
def record_pg_bench_result(self, pg_bench_result: PgBenchRunResult):
|
||||
self.record("scale", pg_bench_result.scale, '', MetricReport.TEST_PARAM)
|
||||
self.record("number_of_clients",
|
||||
pg_bench_result.number_of_clients,
|
||||
'',
|
||||
MetricReport.TEST_PARAM)
|
||||
self.record("number_of_threads",
|
||||
pg_bench_result.number_of_threads,
|
||||
'',
|
||||
MetricReport.TEST_PARAM)
|
||||
def record_pg_bench_result(self, prefix: str, pg_bench_result: PgBenchRunResult):
|
||||
self.record(
|
||||
"number_of_transactions_actually_processed",
|
||||
f"{prefix}.number_of_clients",
|
||||
pg_bench_result.number_of_clients,
|
||||
"",
|
||||
MetricReport.TEST_PARAM,
|
||||
)
|
||||
self.record(
|
||||
f"{prefix}.number_of_threads",
|
||||
pg_bench_result.number_of_threads,
|
||||
"",
|
||||
MetricReport.TEST_PARAM,
|
||||
)
|
||||
self.record(
|
||||
f"{prefix}.number_of_transactions_actually_processed",
|
||||
pg_bench_result.number_of_transactions_actually_processed,
|
||||
'',
|
||||
# thats because this is predefined by test matrix and doesnt change across runs
|
||||
"",
|
||||
# that's because this is predefined by test matrix and doesn't change across runs
|
||||
report=MetricReport.TEST_PARAM,
|
||||
)
|
||||
self.record("latency_average",
|
||||
pg_bench_result.latency_average,
|
||||
unit="ms",
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
self.record("latency_stddev",
|
||||
pg_bench_result.latency_stddev,
|
||||
unit="ms",
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
self.record("tps_including_connection_time",
|
||||
pg_bench_result.tps_including_connection_time,
|
||||
'',
|
||||
report=MetricReport.HIGHER_IS_BETTER)
|
||||
self.record("tps_excluding_connection_time",
|
||||
pg_bench_result.tps_excluding_connection_time,
|
||||
'',
|
||||
report=MetricReport.HIGHER_IS_BETTER)
|
||||
self.record("init_duration",
|
||||
pg_bench_result.init_duration,
|
||||
unit="s",
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
self.record("init_start_timestamp",
|
||||
pg_bench_result.init_start_timestamp,
|
||||
'',
|
||||
MetricReport.TEST_PARAM)
|
||||
self.record("init_end_timestamp",
|
||||
pg_bench_result.init_end_timestamp,
|
||||
'',
|
||||
MetricReport.TEST_PARAM)
|
||||
self.record("run_duration",
|
||||
pg_bench_result.run_duration,
|
||||
unit="s",
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
self.record("run_start_timestamp",
|
||||
pg_bench_result.run_start_timestamp,
|
||||
'',
|
||||
MetricReport.TEST_PARAM)
|
||||
self.record("run_end_timestamp",
|
||||
pg_bench_result.run_end_timestamp,
|
||||
'',
|
||||
MetricReport.TEST_PARAM)
|
||||
self.record(
|
||||
f"{prefix}.latency_average",
|
||||
pg_bench_result.latency_average,
|
||||
unit="ms",
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
if pg_bench_result.latency_stddev is not None:
|
||||
self.record(
|
||||
f"{prefix}.latency_stddev",
|
||||
pg_bench_result.latency_stddev,
|
||||
unit="ms",
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
self.record(f"{prefix}.tps", pg_bench_result.tps, "", report=MetricReport.HIGHER_IS_BETTER)
|
||||
self.record(
|
||||
f"{prefix}.run_duration",
|
||||
pg_bench_result.run_duration,
|
||||
unit="s",
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
self.record(
|
||||
f"{prefix}.run_start_timestamp",
|
||||
pg_bench_result.run_start_timestamp,
|
||||
"",
|
||||
MetricReport.TEST_PARAM,
|
||||
)
|
||||
self.record(
|
||||
f"{prefix}.run_end_timestamp",
|
||||
pg_bench_result.run_end_timestamp,
|
||||
"",
|
||||
MetricReport.TEST_PARAM,
|
||||
)
|
||||
self.record(
|
||||
f"{prefix}.scale",
|
||||
pg_bench_result.scale,
|
||||
"",
|
||||
MetricReport.TEST_PARAM,
|
||||
)
|
||||
|
||||
def get_io_writes(self, pageserver) -> int:
|
||||
def record_pg_bench_init_result(self, prefix: str, result: PgBenchInitResult):
|
||||
test_params = [
|
||||
"start_timestamp",
|
||||
"end_timestamp",
|
||||
]
|
||||
for test_param in test_params:
|
||||
self.record(
|
||||
f"{prefix}.{test_param}", getattr(result, test_param), "", MetricReport.TEST_PARAM
|
||||
)
|
||||
|
||||
metrics = [
|
||||
"duration",
|
||||
"drop_tables",
|
||||
"create_tables",
|
||||
"client_side_generate",
|
||||
"vacuum",
|
||||
"primary_keys",
|
||||
]
|
||||
for metric in metrics:
|
||||
if (value := getattr(result, metric)) is not None:
|
||||
self.record(
|
||||
f"{prefix}.{metric}", value, unit="s", report=MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
|
||||
def get_io_writes(self, pageserver: NeonPageserver) -> int:
|
||||
"""
|
||||
Fetch the "cumulative # of bytes written" metric from the pageserver
|
||||
"""
|
||||
# Fetch all the exposed prometheus metrics from page server
|
||||
all_metrics = pageserver.http_client().get_metrics()
|
||||
# Use a regular expression to extract the one we're interested in
|
||||
#
|
||||
metric_name = r'libmetrics_disk_io_bytes_total{io_operation="write"}'
|
||||
return self.get_int_counter_value(pageserver, metric_name)
|
||||
|
||||
def get_peak_mem(self, pageserver: NeonPageserver) -> int:
|
||||
"""
|
||||
Fetch the "maxrss" metric from the pageserver
|
||||
"""
|
||||
metric_name = r"libmetrics_maxrss_kb"
|
||||
return self.get_int_counter_value(pageserver, metric_name)
|
||||
|
||||
def get_int_counter_value(self, pageserver: NeonPageserver, metric_name: str) -> int:
|
||||
"""Fetch the value of given int counter from pageserver metrics."""
|
||||
# TODO: If we start to collect more of the prometheus metrics in the
|
||||
# performance test suite like this, we should refactor this to load and
|
||||
# parse all the metrics into a more convenient structure in one go.
|
||||
@@ -260,28 +358,18 @@ class ZenithBenchmarker:
|
||||
# The metric should be an integer, as it's a number of bytes. But in general
|
||||
# all prometheus metrics are floats. So to be pedantic, read it as a float
|
||||
# and round to integer.
|
||||
matches = re.search(r'^pageserver_disk_io_bytes{io_operation="write"} (\S+)$',
|
||||
all_metrics,
|
||||
re.MULTILINE)
|
||||
assert matches
|
||||
return int(round(float(matches.group(1))))
|
||||
|
||||
def get_peak_mem(self, pageserver) -> int:
|
||||
"""
|
||||
Fetch the "maxrss" metric from the pageserver
|
||||
"""
|
||||
# Fetch all the exposed prometheus metrics from page server
|
||||
all_metrics = pageserver.http_client().get_metrics()
|
||||
# See comment in get_io_writes()
|
||||
matches = re.search(r'^pageserver_maxrss_kb (\S+)$', all_metrics, re.MULTILINE)
|
||||
assert matches
|
||||
matches = re.search(rf"^{metric_name} (\S+)$", all_metrics, re.MULTILINE)
|
||||
assert matches, f"metric {metric_name} not found"
|
||||
return int(round(float(matches.group(1))))
|
||||
|
||||
def get_timeline_size(self, repo_dir: Path, tenantid: uuid.UUID, timelineid: str):
|
||||
def get_timeline_size(
|
||||
self, repo_dir: Path, tenant_id: TenantId, timeline_id: TimelineId
|
||||
) -> int:
|
||||
"""
|
||||
Calculate the on-disk size of a timeline
|
||||
"""
|
||||
path = "{}/tenants/{}/timelines/{}".format(repo_dir, tenantid.hex, timelineid)
|
||||
path = f"{repo_dir}/tenants/{tenant_id}/timelines/{timeline_id}"
|
||||
|
||||
totalbytes = 0
|
||||
for root, dirs, files in os.walk(path):
|
||||
@@ -291,7 +379,9 @@ class ZenithBenchmarker:
|
||||
return totalbytes
|
||||
|
||||
@contextmanager
|
||||
def record_pageserver_writes(self, pageserver, metric_name):
|
||||
def record_pageserver_writes(
|
||||
self, pageserver: NeonPageserver, metric_name: str
|
||||
) -> Iterator[None]:
|
||||
"""
|
||||
Record bytes written by the pageserver during a test.
|
||||
"""
|
||||
@@ -299,27 +389,29 @@ class ZenithBenchmarker:
|
||||
yield
|
||||
after = self.get_io_writes(pageserver)
|
||||
|
||||
self.record(metric_name,
|
||||
round((after - before) / (1024 * 1024)),
|
||||
"MB",
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
self.record(
|
||||
metric_name,
|
||||
round((after - before) / (1024 * 1024)),
|
||||
"MB",
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def zenbenchmark(record_property) -> Iterator[ZenithBenchmarker]:
|
||||
def zenbenchmark(record_property: Callable[[str, object], None]) -> Iterator[NeonBenchmarker]:
|
||||
"""
|
||||
This is a python decorator for benchmark fixtures. It contains functions for
|
||||
recording measurements, and prints them out at the end.
|
||||
"""
|
||||
benchmarker = ZenithBenchmarker(record_property)
|
||||
benchmarker = NeonBenchmarker(record_property)
|
||||
yield benchmarker
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
def pytest_addoption(parser: Parser):
|
||||
parser.addoption(
|
||||
"--out-dir",
|
||||
dest="out_dir",
|
||||
help="Directory to ouput performance tests results to.",
|
||||
help="Directory to output performance tests results to.",
|
||||
)
|
||||
|
||||
|
||||
@@ -339,7 +431,9 @@ def get_out_path(target_dir: Path, revision: str) -> Path:
|
||||
|
||||
# Hook to print the results at the end
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_terminal_summary(terminalreporter: TerminalReporter, exitstatus: int, config: Config):
|
||||
def pytest_terminal_summary(
|
||||
terminalreporter: TerminalReporter, exitstatus: int, config: Config
|
||||
) -> Iterator[None]:
|
||||
yield
|
||||
revision = os.getenv("GITHUB_SHA", "local")
|
||||
platform = os.getenv("PLATFORM", "local")
|
||||
@@ -354,18 +448,18 @@ def pytest_terminal_summary(terminalreporter: TerminalReporter, exitstatus: int,
|
||||
|
||||
results = []
|
||||
for name, report in reports.items():
|
||||
terminalreporter.write(f"{name}", green=True)
|
||||
terminalreporter.line("")
|
||||
if "[zenith" in name:
|
||||
vanilla_report = reports.get(name.replace("[zenith", "[vanilla"))
|
||||
# terminalreporter.write(f"{name}", green=True)
|
||||
# terminalreporter.line("")
|
||||
if "[neon" in name:
|
||||
vanilla_report = reports.get(name.replace("[neon", "[vanilla"))
|
||||
if vanilla_report:
|
||||
for key, prop in report.user_properties:
|
||||
if prop["unit"] == "s":
|
||||
zenith_value = prop["value"]
|
||||
neon_value = prop["value"]
|
||||
vanilla_value = dict(vanilla_report.user_properties)[key]["value"]
|
||||
ratio = float(zenith_value) / vanilla_value
|
||||
ratio = float(neon_value) / vanilla_value
|
||||
|
||||
results.append((ratio, name.replace("[zenith", "[zenith/vanilla"), prop["name"]))
|
||||
results.append((ratio, name.replace("[neon", "[neon/vanilla"), prop["name"]))
|
||||
|
||||
results.sort(reverse=True)
|
||||
for ratio, test, prop in results:
|
||||
@@ -416,6 +510,5 @@ def pytest_terminal_summary(terminalreporter: TerminalReporter, exitstatus: int,
|
||||
return
|
||||
|
||||
get_out_path(Path(out_dir), revision=revision).write_text(
|
||||
json.dumps({
|
||||
"revision": revision, "platform": platform, "result": result
|
||||
}, indent=4))
|
||||
json.dumps({"revision": revision, "platform": platform, "result": result}, indent=4)
|
||||
)
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import pytest
|
||||
from contextlib import contextmanager
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fixtures.zenith_fixtures import PgBin, PgProtocol, VanillaPostgres, ZenithEnv
|
||||
from fixtures.benchmark_fixture import MetricReport, ZenithBenchmarker
|
||||
from contextlib import _GeneratorContextManager, contextmanager
|
||||
|
||||
# Type-related stuff
|
||||
from typing import Iterator
|
||||
from typing import Dict, Iterator, List
|
||||
|
||||
import pytest
|
||||
from _pytest.fixtures import FixtureRequest
|
||||
from fixtures.benchmark_fixture import MetricReport, NeonBenchmarker
|
||||
from fixtures.neon_fixtures import NeonEnv, PgBin, PgProtocol, RemotePostgres, VanillaPostgres
|
||||
from fixtures.pg_stats import PgStatTable
|
||||
|
||||
|
||||
class PgCompare(ABC):
|
||||
"""Common interface of all postgres implementations, useful for benchmarks.
|
||||
|
||||
This class is a helper class for the zenith_with_baseline fixture. See its documentation
|
||||
This class is a helper class for the neon_with_baseline fixture. See its documentation
|
||||
for more details.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def pg(self) -> PgProtocol:
|
||||
@@ -26,19 +29,20 @@ class PgCompare(ABC):
|
||||
pass
|
||||
|
||||
@property
|
||||
def zenbenchmark(self) -> ZenithBenchmarker:
|
||||
@abstractmethod
|
||||
def zenbenchmark(self) -> NeonBenchmarker:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def flush(self) -> None:
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def report_peak_memory_use(self) -> None:
|
||||
def report_peak_memory_use(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def report_size(self) -> None:
|
||||
def report_size(self):
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
@@ -51,71 +55,120 @@ class PgCompare(ABC):
|
||||
def record_duration(self, out_name):
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def record_pg_stats(self, pg_stats: List[PgStatTable]) -> Iterator[None]:
|
||||
init_data = self._retrieve_pg_stats(pg_stats)
|
||||
|
||||
class ZenithCompare(PgCompare):
|
||||
"""PgCompare interface for the zenith stack."""
|
||||
def __init__(self,
|
||||
zenbenchmark: ZenithBenchmarker,
|
||||
zenith_simple_env: ZenithEnv,
|
||||
pg_bin: PgBin,
|
||||
branch_name):
|
||||
self.env = zenith_simple_env
|
||||
yield
|
||||
|
||||
data = self._retrieve_pg_stats(pg_stats)
|
||||
|
||||
for k in set(init_data) & set(data):
|
||||
self.zenbenchmark.record(k, data[k] - init_data[k], "", MetricReport.HIGHER_IS_BETTER)
|
||||
|
||||
def _retrieve_pg_stats(self, pg_stats: List[PgStatTable]) -> Dict[str, int]:
|
||||
results: Dict[str, int] = {}
|
||||
|
||||
with self.pg.connect().cursor() as cur:
|
||||
for pg_stat in pg_stats:
|
||||
cur.execute(pg_stat.query)
|
||||
row = cur.fetchone()
|
||||
assert row is not None
|
||||
assert len(row) == len(pg_stat.columns)
|
||||
|
||||
for col, val in zip(pg_stat.columns, row):
|
||||
results[f"{pg_stat.table}.{col}"] = int(val)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class NeonCompare(PgCompare):
|
||||
"""PgCompare interface for the neon stack."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
zenbenchmark: NeonBenchmarker,
|
||||
neon_simple_env: NeonEnv,
|
||||
pg_bin: PgBin,
|
||||
branch_name: str,
|
||||
):
|
||||
self.env = neon_simple_env
|
||||
self._zenbenchmark = zenbenchmark
|
||||
self._pg_bin = pg_bin
|
||||
self.pageserver_http_client = self.env.pageserver.http_client()
|
||||
|
||||
# We only use one branch and one timeline
|
||||
self.branch = branch_name
|
||||
self.env.zenith_cli.create_branch(self.branch, "empty")
|
||||
self._pg = self.env.postgres.create_start(self.branch)
|
||||
self.timeline = self.pg.safe_psql("SHOW zenith.zenith_timeline")[0][0]
|
||||
|
||||
# Long-lived cursor, useful for flushing
|
||||
self.psconn = self.env.pageserver.connect()
|
||||
self.pscur = self.psconn.cursor()
|
||||
self.env.neon_cli.create_branch(branch_name, "empty")
|
||||
self._pg = self.env.postgres.create_start(branch_name)
|
||||
self.timeline = self.pg.safe_psql("SHOW neon.timeline_id")[0][0]
|
||||
|
||||
@property
|
||||
def pg(self):
|
||||
def pg(self) -> PgProtocol:
|
||||
return self._pg
|
||||
|
||||
@property
|
||||
def zenbenchmark(self):
|
||||
def zenbenchmark(self) -> NeonBenchmarker:
|
||||
return self._zenbenchmark
|
||||
|
||||
@property
|
||||
def pg_bin(self):
|
||||
def pg_bin(self) -> PgBin:
|
||||
return self._pg_bin
|
||||
|
||||
def flush(self):
|
||||
self.pscur.execute(f"do_gc {self.env.initial_tenant.hex} {self.timeline} 0")
|
||||
self.pageserver_http_client.timeline_gc(self.env.initial_tenant, self.timeline, 0)
|
||||
|
||||
def report_peak_memory_use(self) -> None:
|
||||
self.zenbenchmark.record("peak_mem",
|
||||
self.zenbenchmark.get_peak_mem(self.env.pageserver) / 1024,
|
||||
'MB',
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
def compact(self):
|
||||
self.pageserver_http_client.timeline_compact(self.env.initial_tenant, self.timeline)
|
||||
|
||||
def report_size(self) -> None:
|
||||
timeline_size = self.zenbenchmark.get_timeline_size(self.env.repo_dir,
|
||||
self.env.initial_tenant,
|
||||
self.timeline)
|
||||
self.zenbenchmark.record('size',
|
||||
timeline_size / (1024 * 1024),
|
||||
'MB',
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
def report_peak_memory_use(self):
|
||||
self.zenbenchmark.record(
|
||||
"peak_mem",
|
||||
self.zenbenchmark.get_peak_mem(self.env.pageserver) / 1024,
|
||||
"MB",
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
|
||||
def record_pageserver_writes(self, out_name):
|
||||
def report_size(self):
|
||||
timeline_size = self.zenbenchmark.get_timeline_size(
|
||||
self.env.repo_dir, self.env.initial_tenant, self.timeline
|
||||
)
|
||||
self.zenbenchmark.record(
|
||||
"size", timeline_size / (1024 * 1024), "MB", report=MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
|
||||
params = f'{{tenant_id="{self.env.initial_tenant}",timeline_id="{self.timeline}"}}'
|
||||
total_files = self.zenbenchmark.get_int_counter_value(
|
||||
self.env.pageserver, "pageserver_created_persistent_files_total" + params
|
||||
)
|
||||
total_bytes = self.zenbenchmark.get_int_counter_value(
|
||||
self.env.pageserver, "pageserver_written_persistent_bytes_total" + params
|
||||
)
|
||||
self.zenbenchmark.record(
|
||||
"data_uploaded", total_bytes / (1024 * 1024), "MB", report=MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
self.zenbenchmark.record(
|
||||
"num_files_uploaded", total_files, "", report=MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
|
||||
def record_pageserver_writes(self, out_name: str) -> _GeneratorContextManager[None]:
|
||||
return self.zenbenchmark.record_pageserver_writes(self.env.pageserver, out_name)
|
||||
|
||||
def record_duration(self, out_name):
|
||||
def record_duration(self, out_name: str) -> _GeneratorContextManager[None]:
|
||||
return self.zenbenchmark.record_duration(out_name)
|
||||
|
||||
|
||||
class VanillaCompare(PgCompare):
|
||||
"""PgCompare interface for vanilla postgres."""
|
||||
def __init__(self, zenbenchmark, vanilla_pg: VanillaPostgres):
|
||||
|
||||
def __init__(self, zenbenchmark: NeonBenchmarker, vanilla_pg: VanillaPostgres):
|
||||
self._pg = vanilla_pg
|
||||
self._zenbenchmark = zenbenchmark
|
||||
vanilla_pg.configure(['shared_buffers=1MB'])
|
||||
vanilla_pg.configure(
|
||||
[
|
||||
"shared_buffers=1MB",
|
||||
"synchronous_commit=off",
|
||||
]
|
||||
)
|
||||
vanilla_pg.start()
|
||||
|
||||
# Long-lived cursor, useful for flushing
|
||||
@@ -123,61 +176,112 @@ class VanillaCompare(PgCompare):
|
||||
self.cur = self.conn.cursor()
|
||||
|
||||
@property
|
||||
def pg(self):
|
||||
def pg(self) -> PgProtocol:
|
||||
return self._pg
|
||||
|
||||
@property
|
||||
def zenbenchmark(self):
|
||||
def zenbenchmark(self) -> NeonBenchmarker:
|
||||
return self._zenbenchmark
|
||||
|
||||
@property
|
||||
def pg_bin(self):
|
||||
def pg_bin(self) -> PgBin:
|
||||
return self._pg.pg_bin
|
||||
|
||||
def flush(self):
|
||||
self.cur.execute("checkpoint")
|
||||
|
||||
def report_peak_memory_use(self) -> None:
|
||||
def report_peak_memory_use(self):
|
||||
pass # TODO find something
|
||||
|
||||
def report_size(self) -> None:
|
||||
data_size = self.pg.get_subdir_size('base')
|
||||
self.zenbenchmark.record('data_size',
|
||||
data_size / (1024 * 1024),
|
||||
'MB',
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
wal_size = self.pg.get_subdir_size('pg_wal')
|
||||
self.zenbenchmark.record('wal_size',
|
||||
wal_size / (1024 * 1024),
|
||||
'MB',
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
def report_size(self):
|
||||
data_size = self.pg.get_subdir_size("base")
|
||||
self.zenbenchmark.record(
|
||||
"data_size", data_size / (1024 * 1024), "MB", report=MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
wal_size = self.pg.get_subdir_size("pg_wal")
|
||||
self.zenbenchmark.record(
|
||||
"wal_size", wal_size / (1024 * 1024), "MB", report=MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def record_pageserver_writes(self, out_name):
|
||||
def record_pageserver_writes(self, out_name: str) -> Iterator[None]:
|
||||
yield # Do nothing
|
||||
|
||||
def record_duration(self, out_name):
|
||||
def record_duration(self, out_name: str) -> _GeneratorContextManager[None]:
|
||||
return self.zenbenchmark.record_duration(out_name)
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def zenith_compare(request, zenbenchmark, pg_bin, zenith_simple_env) -> ZenithCompare:
|
||||
class RemoteCompare(PgCompare):
|
||||
"""PgCompare interface for a remote postgres instance."""
|
||||
|
||||
def __init__(self, zenbenchmark: NeonBenchmarker, remote_pg: RemotePostgres):
|
||||
self._pg = remote_pg
|
||||
self._zenbenchmark = zenbenchmark
|
||||
|
||||
# Long-lived cursor, useful for flushing
|
||||
self.conn = self.pg.connect()
|
||||
self.cur = self.conn.cursor()
|
||||
|
||||
@property
|
||||
def pg(self) -> PgProtocol:
|
||||
return self._pg
|
||||
|
||||
@property
|
||||
def zenbenchmark(self) -> NeonBenchmarker:
|
||||
return self._zenbenchmark
|
||||
|
||||
@property
|
||||
def pg_bin(self) -> PgBin:
|
||||
return self._pg.pg_bin
|
||||
|
||||
def flush(self):
|
||||
# TODO: flush the remote pageserver
|
||||
pass
|
||||
|
||||
def report_peak_memory_use(self):
|
||||
# TODO: get memory usage from remote pageserver
|
||||
pass
|
||||
|
||||
def report_size(self):
|
||||
# TODO: get storage size from remote pageserver
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def record_pageserver_writes(self, out_name: str) -> Iterator[None]:
|
||||
yield # Do nothing
|
||||
|
||||
def record_duration(self, out_name: str) -> _GeneratorContextManager[None]:
|
||||
return self.zenbenchmark.record_duration(out_name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def neon_compare(
|
||||
request: FixtureRequest,
|
||||
zenbenchmark: NeonBenchmarker,
|
||||
pg_bin: PgBin,
|
||||
neon_simple_env: NeonEnv,
|
||||
) -> NeonCompare:
|
||||
branch_name = request.node.name
|
||||
return ZenithCompare(zenbenchmark, zenith_simple_env, pg_bin, branch_name)
|
||||
return NeonCompare(zenbenchmark, neon_simple_env, pg_bin, branch_name)
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def vanilla_compare(zenbenchmark, vanilla_pg) -> VanillaCompare:
|
||||
@pytest.fixture(scope="function")
|
||||
def vanilla_compare(zenbenchmark: NeonBenchmarker, vanilla_pg: VanillaPostgres) -> VanillaCompare:
|
||||
return VanillaCompare(zenbenchmark, vanilla_pg)
|
||||
|
||||
|
||||
@pytest.fixture(params=["vanilla_compare", "zenith_compare"], ids=["vanilla", "zenith"])
|
||||
def zenith_with_baseline(request) -> PgCompare:
|
||||
"""Parameterized fixture that helps compare zenith against vanilla postgres.
|
||||
@pytest.fixture(scope="function")
|
||||
def remote_compare(zenbenchmark: NeonBenchmarker, remote_pg: RemotePostgres) -> RemoteCompare:
|
||||
return RemoteCompare(zenbenchmark, remote_pg)
|
||||
|
||||
|
||||
@pytest.fixture(params=["vanilla_compare", "neon_compare"], ids=["vanilla", "neon"])
|
||||
def neon_with_baseline(request: FixtureRequest) -> PgCompare:
|
||||
"""Parameterized fixture that helps compare neon against vanilla postgres.
|
||||
|
||||
A test that uses this fixture turns into a parameterized test that runs against:
|
||||
1. A vanilla postgres instance
|
||||
2. A simple zenith env (see zenith_simple_env)
|
||||
2. A simple neon env (see neon_simple_env)
|
||||
3. Possibly other postgres protocol implementations.
|
||||
|
||||
The main goal of this fixture is to make it easier for people to read and write
|
||||
@@ -189,12 +293,10 @@ def zenith_with_baseline(request) -> PgCompare:
|
||||
of that.
|
||||
|
||||
If a test requires some one-off special implementation-specific logic, use of
|
||||
isinstance(zenith_with_baseline, ZenithCompare) is encouraged. Though if that
|
||||
isinstance(neon_with_baseline, NeonCompare) is encouraged. Though if that
|
||||
implementation-specific logic is widely useful across multiple tests, it might
|
||||
make sense to add methods to the PgCompare class.
|
||||
"""
|
||||
fixture = request.getfixturevalue(request.param)
|
||||
if isinstance(fixture, PgCompare):
|
||||
return fixture
|
||||
else:
|
||||
raise AssertionError(f"test error: fixture {request.param} is not PgCompare")
|
||||
fixture = request.getfixturevalue(request.param) # type: ignore
|
||||
assert isinstance(fixture, PgCompare), f"test error: fixture {fixture} is not PgCompare"
|
||||
return fixture
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
"""
|
||||
This file configures logging to use in python tests.
|
||||
Logs are automatically captured and shown in their
|
||||
@@ -22,20 +23,16 @@ https://docs.pytest.org/en/6.2.x/logging.html
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"loggers": {
|
||||
"root": {
|
||||
"level": "INFO"
|
||||
},
|
||||
"root.wal_acceptor_async": {
|
||||
"level": "INFO" # a lot of logs on DEBUG level
|
||||
}
|
||||
}
|
||||
"root": {"level": "INFO"},
|
||||
"root.safekeeper_async": {"level": "INFO"}, # a lot of logs on DEBUG level
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def getLogger(name='root') -> logging.Logger:
|
||||
def getLogger(name="root") -> logging.Logger:
|
||||
"""Method to get logger for tests.
|
||||
|
||||
Should be used to get correctly initialized logger. """
|
||||
Should be used to get correctly initialized logger."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
|
||||
65
test_runner/fixtures/metrics.py
Normal file
65
test_runner/fixtures/metrics.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from prometheus_client.parser import text_string_to_metric_families
|
||||
from prometheus_client.samples import Sample
|
||||
|
||||
|
||||
class Metrics:
|
||||
metrics: Dict[str, List[Sample]]
|
||||
name: str
|
||||
|
||||
def __init__(self, name: str = ""):
|
||||
self.metrics = defaultdict(list)
|
||||
self.name = name
|
||||
|
||||
def query_all(self, name: str, filter: Dict[str, str]) -> List[Sample]:
|
||||
res = []
|
||||
for sample in self.metrics[name]:
|
||||
try:
|
||||
if all(sample.labels[k] == v for k, v in filter.items()):
|
||||
res.append(sample)
|
||||
except KeyError:
|
||||
pass
|
||||
return res
|
||||
|
||||
def query_one(self, name: str, filter: Optional[Dict[str, str]] = None) -> Sample:
|
||||
res = self.query_all(name, filter or {})
|
||||
assert len(res) == 1, f"expected single sample for {name} {filter}, found {res}"
|
||||
return res[0]
|
||||
|
||||
|
||||
def parse_metrics(text: str, name: str = "") -> Metrics:
|
||||
metrics = Metrics(name)
|
||||
gen = text_string_to_metric_families(text)
|
||||
for family in gen:
|
||||
for sample in family.samples:
|
||||
metrics.metrics[sample.name].append(sample)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
PAGESERVER_PER_TENANT_METRICS: Tuple[str, ...] = (
|
||||
"pageserver_current_logical_size",
|
||||
"pageserver_current_physical_size",
|
||||
"pageserver_getpage_reconstruct_seconds_bucket",
|
||||
"pageserver_getpage_reconstruct_seconds_count",
|
||||
"pageserver_getpage_reconstruct_seconds_sum",
|
||||
"pageserver_io_operations_bytes_total",
|
||||
"pageserver_io_operations_seconds_bucket",
|
||||
"pageserver_io_operations_seconds_count",
|
||||
"pageserver_io_operations_seconds_sum",
|
||||
"pageserver_last_record_lsn",
|
||||
"pageserver_materialized_cache_hits_total",
|
||||
"pageserver_smgr_query_seconds_bucket",
|
||||
"pageserver_smgr_query_seconds_count",
|
||||
"pageserver_smgr_query_seconds_sum",
|
||||
"pageserver_storage_operations_seconds_bucket",
|
||||
"pageserver_storage_operations_seconds_count",
|
||||
"pageserver_storage_operations_seconds_sum",
|
||||
"pageserver_wait_lsn_seconds_bucket",
|
||||
"pageserver_wait_lsn_seconds_count",
|
||||
"pageserver_wait_lsn_seconds_sum",
|
||||
"pageserver_created_persistent_files_total",
|
||||
"pageserver_written_persistent_bytes_total",
|
||||
)
|
||||
2905
test_runner/fixtures/neon_fixtures.py
Normal file
2905
test_runner/fixtures/neon_fixtures.py
Normal file
File diff suppressed because it is too large
Load Diff
60
test_runner/fixtures/pg_stats.py
Normal file
60
test_runner/fixtures/pg_stats.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from functools import cached_property
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class PgStatTable:
|
||||
table: str
|
||||
columns: List[str]
|
||||
additional_query: str
|
||||
|
||||
def __init__(self, table: str, columns: List[str], filter_query: str = ""):
|
||||
self.table = table
|
||||
self.columns = columns
|
||||
self.additional_query = filter_query
|
||||
|
||||
@cached_property
|
||||
def query(self) -> str:
|
||||
return f"SELECT {','.join(self.columns)} FROM {self.table} {self.additional_query}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def pg_stats_rw() -> List[PgStatTable]:
|
||||
return [
|
||||
PgStatTable(
|
||||
"pg_stat_database",
|
||||
["tup_returned", "tup_fetched", "tup_inserted", "tup_updated", "tup_deleted"],
|
||||
"WHERE datname='postgres'",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def pg_stats_ro() -> List[PgStatTable]:
|
||||
return [
|
||||
PgStatTable(
|
||||
"pg_stat_database", ["tup_returned", "tup_fetched"], "WHERE datname='postgres'"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def pg_stats_wo() -> List[PgStatTable]:
|
||||
return [
|
||||
PgStatTable(
|
||||
"pg_stat_database",
|
||||
["tup_inserted", "tup_updated", "tup_deleted"],
|
||||
"WHERE datname='postgres'",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def pg_stats_wal() -> List[PgStatTable]:
|
||||
return [
|
||||
PgStatTable(
|
||||
"pg_stat_wal",
|
||||
["wal_records", "wal_fpi", "wal_bytes", "wal_buffers_full", "wal_write"],
|
||||
)
|
||||
]
|
||||
@@ -1,4 +1,9 @@
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
from _pytest.config import Config
|
||||
from _pytest.config.argparsing import Parser
|
||||
|
||||
"""
|
||||
This plugin allows tests to be marked as slow using pytest.mark.slow. By default slow
|
||||
tests are excluded. They need to be specifically requested with the --runslow flag in
|
||||
@@ -8,15 +13,15 @@ Copied from here: https://docs.pytest.org/en/latest/example/simple.html
|
||||
"""
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
def pytest_addoption(parser: Parser):
|
||||
parser.addoption("--runslow", action="store_true", default=False, help="run slow tests")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
def pytest_configure(config: Config):
|
||||
config.addinivalue_line("markers", "slow: mark test as slow to run")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
def pytest_collection_modifyitems(config: Config, items: List[Any]):
|
||||
if config.getoption("--runslow"):
|
||||
# --runslow given in cli: do not skip slow tests
|
||||
return
|
||||
|
||||
95
test_runner/fixtures/types.py
Normal file
95
test_runner/fixtures/types.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import random
|
||||
from functools import total_ordering
|
||||
from typing import Any, Type, TypeVar, Union
|
||||
|
||||
T = TypeVar("T", bound="Id")
|
||||
|
||||
|
||||
@total_ordering
|
||||
class Lsn:
|
||||
"""
|
||||
Datatype for an LSN. Internally it is a 64-bit integer, but the string
|
||||
representation is like "1/123abcd". See also pg_lsn datatype in Postgres
|
||||
"""
|
||||
|
||||
def __init__(self, x: Union[int, str]):
|
||||
if isinstance(x, int):
|
||||
self.lsn_int = x
|
||||
else:
|
||||
"""Convert lsn from hex notation to int."""
|
||||
l, r = x.split("/")
|
||||
self.lsn_int = (int(l, 16) << 32) + int(r, 16)
|
||||
assert 0 <= self.lsn_int <= 0xFFFFFFFF_FFFFFFFF
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Convert lsn from int to standard hex notation."""
|
||||
return f"{(self.lsn_int >> 32):X}/{(self.lsn_int & 0xFFFFFFFF):X}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'Lsn("{str(self)}")'
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.lsn_int
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Lsn):
|
||||
return NotImplemented
|
||||
return self.lsn_int < other.lsn_int
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Lsn):
|
||||
return NotImplemented
|
||||
return self.lsn_int == other.lsn_int
|
||||
|
||||
# Returns the difference between two Lsns, in bytes
|
||||
def __sub__(self, other: Any) -> int:
|
||||
if not isinstance(other, Lsn):
|
||||
return NotImplemented
|
||||
return self.lsn_int - other.lsn_int
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.lsn_int)
|
||||
|
||||
|
||||
@total_ordering
|
||||
class Id:
|
||||
"""
|
||||
Datatype for a Neon tenant and timeline IDs. Internally it's a 16-byte array, and
|
||||
the string representation is in hex. This corresponds to the Id / TenantId /
|
||||
TimelineIds in the Rust code.
|
||||
"""
|
||||
|
||||
def __init__(self, x: str):
|
||||
self.id = bytearray.fromhex(x)
|
||||
assert len(self.id) == 16
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.id.hex()
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
if not isinstance(other, type(self)):
|
||||
return NotImplemented
|
||||
return self.id < other.id
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if not isinstance(other, type(self)):
|
||||
return NotImplemented
|
||||
return self.id == other.id
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self.id))
|
||||
|
||||
@classmethod
|
||||
def generate(cls: Type[T]) -> T:
|
||||
"""Generate a random ID"""
|
||||
return cls(random.randbytes(16).hex())
|
||||
|
||||
|
||||
class TenantId(Id):
|
||||
def __repr__(self) -> str:
|
||||
return f'`TenantId("{self.id.hex()}")'
|
||||
|
||||
|
||||
class TimelineId(Id):
|
||||
def __repr__(self) -> str:
|
||||
return f'TimelineId("{self.id.hex()}")'
|
||||
@@ -1,29 +1,27 @@
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Tuple, TypeVar
|
||||
|
||||
from typing import Any, List
|
||||
import allure # type: ignore
|
||||
from fixtures.log_helper import log
|
||||
from psycopg2.extensions import cursor
|
||||
|
||||
Fn = TypeVar("Fn", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def get_self_dir() -> str:
|
||||
""" Get the path to the directory where this script lives. """
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
def get_self_dir() -> Path:
|
||||
"""Get the path to the directory where this script lives."""
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def mkdir_if_needed(path: str) -> None:
|
||||
""" Create a directory if it doesn't already exist
|
||||
|
||||
Note this won't try to create intermediate directories.
|
||||
"""
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except FileExistsError:
|
||||
pass
|
||||
assert os.path.isdir(path)
|
||||
|
||||
|
||||
def subprocess_capture(capture_dir: str, cmd: List[str], **kwargs: Any) -> str:
|
||||
""" Run a process and capture its output
|
||||
def subprocess_capture(capture_dir: Path, cmd: List[str], **kwargs: Any) -> str:
|
||||
"""Run a process and capture its output
|
||||
|
||||
Output will go to files named "cmd_NNN.stdout" and "cmd_NNN.stderr"
|
||||
where "cmd" is the name of the program and NNN is an incrementing
|
||||
@@ -32,16 +30,22 @@ def subprocess_capture(capture_dir: str, cmd: List[str], **kwargs: Any) -> str:
|
||||
If those files already exist, we will overwrite them.
|
||||
Returns basepath for files with captured output.
|
||||
"""
|
||||
assert type(cmd) is list
|
||||
base = os.path.basename(cmd[0]) + '_{}'.format(global_counter())
|
||||
assert isinstance(cmd, list)
|
||||
base = f"{os.path.basename(cmd[0])}_{global_counter()}"
|
||||
basepath = os.path.join(capture_dir, base)
|
||||
stdout_filename = basepath + '.stdout'
|
||||
stderr_filename = basepath + '.stderr'
|
||||
stdout_filename = f"{basepath}.stdout"
|
||||
stderr_filename = f"{basepath}.stderr"
|
||||
|
||||
with open(stdout_filename, 'w') as stdout_f:
|
||||
with open(stderr_filename, 'w') as stderr_f:
|
||||
log.info('(capturing output to "{}.stdout")'.format(base))
|
||||
subprocess.run(cmd, **kwargs, stdout=stdout_f, stderr=stderr_f)
|
||||
try:
|
||||
with open(stdout_filename, "w") as stdout_f:
|
||||
with open(stderr_filename, "w") as stderr_f:
|
||||
log.info(f'Capturing stdout to "{base}.stdout" and stderr to "{base}.stderr"')
|
||||
subprocess.run(cmd, **kwargs, stdout=stdout_f, stderr=stderr_f)
|
||||
finally:
|
||||
# Remove empty files if there is no output
|
||||
for filename in (stdout_filename, stderr_filename):
|
||||
if os.stat(filename).st_size == 0:
|
||||
os.remove(filename)
|
||||
|
||||
return basepath
|
||||
|
||||
@@ -50,7 +54,7 @@ _global_counter = 0
|
||||
|
||||
|
||||
def global_counter() -> int:
|
||||
""" A really dumb global counter.
|
||||
"""A really dumb global counter.
|
||||
|
||||
This is useful for giving output files a unique number, so if we run the
|
||||
same command multiple times we can keep their output separate.
|
||||
@@ -60,22 +64,182 @@ def global_counter() -> int:
|
||||
return _global_counter
|
||||
|
||||
|
||||
def lsn_to_hex(num: int) -> str:
|
||||
""" Convert lsn from int to standard hex notation. """
|
||||
return "{:X}/{:X}".format(num >> 32, num & 0xffffffff)
|
||||
|
||||
|
||||
def lsn_from_hex(lsn_hex: str) -> int:
|
||||
""" Convert lsn from hex notation to int. """
|
||||
l, r = lsn_hex.split('/')
|
||||
return (int(l, 16) << 32) + int(r, 16)
|
||||
|
||||
|
||||
def print_gc_result(row):
|
||||
def print_gc_result(row: Dict[str, Any]):
|
||||
log.info("GC duration {elapsed} ms".format_map(row))
|
||||
log.info(
|
||||
" REL total: {layer_relfiles_total}, needed_by_cutoff {layer_relfiles_needed_by_cutoff}, needed_by_branches: {layer_relfiles_needed_by_branches}, not_updated: {layer_relfiles_not_updated}, needed_as_tombstone {layer_relfiles_needed_as_tombstone}, removed: {layer_relfiles_removed}, dropped: {layer_relfiles_dropped}"
|
||||
.format_map(row))
|
||||
log.info(
|
||||
" NONREL total: {layer_nonrelfiles_total}, needed_by_cutoff {layer_nonrelfiles_needed_by_cutoff}, needed_by_branches: {layer_nonrelfiles_needed_by_branches}, not_updated: {layer_nonrelfiles_not_updated}, needed_as_tombstone {layer_nonrelfiles_needed_as_tombstone}, removed: {layer_nonrelfiles_removed}, dropped: {layer_nonrelfiles_dropped}"
|
||||
.format_map(row))
|
||||
" total: {layers_total}, needed_by_cutoff {layers_needed_by_cutoff}, needed_by_pitr {layers_needed_by_pitr}"
|
||||
" needed_by_branches: {layers_needed_by_branches}, not_updated: {layers_not_updated}, removed: {layers_removed}".format_map(
|
||||
row
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def etcd_path() -> Path:
|
||||
path_output = shutil.which("etcd")
|
||||
if path_output is None:
|
||||
raise RuntimeError("etcd not found in PATH")
|
||||
return Path(path_output)
|
||||
|
||||
|
||||
def query_scalar(cur: cursor, query: str) -> Any:
|
||||
"""
|
||||
It is a convenience wrapper to avoid repetitions
|
||||
of cur.execute(); cur.fetchone()[0]
|
||||
|
||||
And this is mypy friendly, because without None
|
||||
check mypy says that Optional is not indexable.
|
||||
"""
|
||||
cur.execute(query)
|
||||
var = cur.fetchone()
|
||||
assert var is not None
|
||||
return var[0]
|
||||
|
||||
|
||||
# Traverse directory to get total size.
|
||||
def get_dir_size(path: str) -> int:
|
||||
"""Return size in bytes."""
|
||||
totalbytes = 0
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in files:
|
||||
try:
|
||||
totalbytes += os.path.getsize(os.path.join(root, name))
|
||||
except FileNotFoundError:
|
||||
pass # file could be concurrently removed
|
||||
|
||||
return totalbytes
|
||||
|
||||
|
||||
def get_timeline_dir_size(path: Path) -> int:
|
||||
"""Get the timeline directory's total size, which only counts the layer files' size."""
|
||||
sz = 0
|
||||
for dir_entry in path.iterdir():
|
||||
with contextlib.suppress(Exception):
|
||||
# file is an image layer
|
||||
_ = parse_image_layer(dir_entry.name)
|
||||
sz += dir_entry.stat().st_size
|
||||
continue
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
# file is a delta layer
|
||||
_ = parse_delta_layer(dir_entry.name)
|
||||
sz += dir_entry.stat().st_size
|
||||
return sz
|
||||
|
||||
|
||||
def parse_image_layer(f_name: str) -> Tuple[int, int, int]:
|
||||
"""Parse an image layer file name. Return key start, key end, and snapshot lsn"""
|
||||
parts = f_name.split("__")
|
||||
key_parts = parts[0].split("-")
|
||||
return int(key_parts[0], 16), int(key_parts[1], 16), int(parts[1], 16)
|
||||
|
||||
|
||||
def parse_delta_layer(f_name: str) -> Tuple[int, int, int, int]:
|
||||
"""Parse a delta layer file name. Return key start, key end, lsn start, and lsn end"""
|
||||
parts = f_name.split("__")
|
||||
key_parts = parts[0].split("-")
|
||||
lsn_parts = parts[1].split("-")
|
||||
return (
|
||||
int(key_parts[0], 16),
|
||||
int(key_parts[1], 16),
|
||||
int(lsn_parts[0], 16),
|
||||
int(lsn_parts[1], 16),
|
||||
)
|
||||
|
||||
|
||||
def get_scale_for_db(size_mb: int) -> int:
|
||||
"""Returns pgbench scale factor for given target db size in MB.
|
||||
|
||||
Ref https://www.cybertec-postgresql.com/en/a-formula-to-calculate-pgbench-scaling-factor-for-target-db-size/
|
||||
"""
|
||||
|
||||
return round(0.06689 * size_mb - 0.5)
|
||||
|
||||
|
||||
ATTACHMENT_NAME_REGEX: re.Pattern = re.compile( # type: ignore[type-arg]
|
||||
r"flamegraph\.svg|regression\.diffs|.+\.(?:log|stderr|stdout|filediff|metrics|html)"
|
||||
)
|
||||
|
||||
|
||||
def allure_attach_from_dir(dir: Path):
|
||||
"""Attach all non-empty files from `dir` that matches `ATTACHMENT_NAME_REGEX` to Allure report"""
|
||||
|
||||
for attachment in Path(dir).glob("**/*"):
|
||||
if ATTACHMENT_NAME_REGEX.fullmatch(attachment.name) and attachment.stat().st_size > 0:
|
||||
source = str(attachment)
|
||||
name = str(attachment.relative_to(dir))
|
||||
|
||||
# compress files larger than 1Mb, they're hardly readable in a browser
|
||||
if attachment.stat().st_size > 1024 * 1024:
|
||||
source = f"{attachment}.tar.gz"
|
||||
with tarfile.open(source, "w:gz") as tar:
|
||||
tar.add(attachment, arcname=attachment.name)
|
||||
name = f"{name}.tar.gz"
|
||||
|
||||
if source.endswith(".tar.gz"):
|
||||
attachment_type = "application/gzip"
|
||||
extension = "tar.gz"
|
||||
elif source.endswith(".svg"):
|
||||
attachment_type = "image/svg+xml"
|
||||
extension = "svg"
|
||||
elif source.endswith(".html"):
|
||||
attachment_type = "text/html"
|
||||
extension = "html"
|
||||
else:
|
||||
attachment_type = "text/plain"
|
||||
extension = attachment.suffix.removeprefix(".")
|
||||
|
||||
allure.attach.file(source, name, attachment_type, extension)
|
||||
|
||||
|
||||
def start_in_background(
|
||||
command: list[str], cwd: Path, log_file_name: str, is_started: Fn
|
||||
) -> subprocess.Popen[bytes]:
|
||||
"""Starts a process, creates the logfile and redirects stderr and stdout there. Runs the start checks before the process is started, or errors."""
|
||||
|
||||
log.info(f'Running command "{" ".join(command)}"')
|
||||
|
||||
with open(cwd / log_file_name, "wb") as log_file:
|
||||
spawned_process = subprocess.Popen(command, stdout=log_file, stderr=log_file, cwd=cwd)
|
||||
error = None
|
||||
try:
|
||||
return_code = spawned_process.poll()
|
||||
if return_code is not None:
|
||||
error = f"expected subprocess to run but it exited with code {return_code}"
|
||||
else:
|
||||
attempts = 10
|
||||
try:
|
||||
wait_until(
|
||||
number_of_iterations=attempts,
|
||||
interval=1,
|
||||
func=is_started,
|
||||
)
|
||||
except Exception:
|
||||
error = f"Failed to get correct status from subprocess in {attempts} attempts"
|
||||
except Exception as e:
|
||||
error = f"expected subprocess to start but it failed with exception: {e}"
|
||||
|
||||
if error is not None:
|
||||
log.error(error)
|
||||
spawned_process.kill()
|
||||
raise Exception(f"Failed to run subprocess as {command}, reason: {error}")
|
||||
|
||||
log.info("subprocess spawned")
|
||||
return spawned_process
|
||||
|
||||
|
||||
def wait_until(number_of_iterations: int, interval: float, func: Fn):
|
||||
"""
|
||||
Wait until 'func' returns successfully, without exception. Returns the
|
||||
last return value from the function.
|
||||
"""
|
||||
last_exception = None
|
||||
for i in range(number_of_iterations):
|
||||
try:
|
||||
res = func()
|
||||
except Exception as e:
|
||||
log.info("waiting for %s iteration %s failed", func, i + 1)
|
||||
last_exception = e
|
||||
time.sleep(interval)
|
||||
continue
|
||||
return res
|
||||
raise Exception("timed out while waiting for %s" % func) from last_exception
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
42
test_runner/performance/README.md
Normal file
42
test_runner/performance/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Running locally
|
||||
|
||||
First make a release build. The profiling flag is optional, used only for tests that
|
||||
generate flame graphs. The `-s` flag just silences a lot of output, and makes it
|
||||
easier to see if you have compile errors without scrolling up.
|
||||
`BUILD_TYPE=release CARGO_BUILD_FLAGS="--features=testing,profiling" make -s -j8`
|
||||
|
||||
NOTE: the `profiling` flag only works on linux because we use linux-specific
|
||||
libc APIs like `libc::timer_t`.
|
||||
|
||||
Then run the tests
|
||||
`NEON_BIN=./target/release poetry run pytest test_runner/performance"`
|
||||
|
||||
Some handy pytest flags for local development:
|
||||
- `-x` tells pytest to stop on first error
|
||||
- `-s` shows test output
|
||||
- `-k` selects a test to run
|
||||
- `--timeout=0` disables our default timeout of 300s (see `setup.cfg`)
|
||||
|
||||
# What performance tests do we have and how we run them
|
||||
|
||||
Performance tests are built using the same infrastructure as our usual python integration tests. There are some extra fixtures that help to collect performance metrics, and to run tests against both vanilla PostgreSQL and Neon for comparison.
|
||||
|
||||
## Tests that are run against local installation
|
||||
|
||||
Most of the performance tests run against a local installation. This is not very representative of a production environment. Firstly, Postgres, safekeeper(s) and the pageserver have to share CPU and I/O resources, which can add noise to the results. Secondly, network overhead is eliminated.
|
||||
|
||||
In the CI, the performance tests are run in the same environment as the other integration tests. We don't have control over the host that the CI runs on, so the environment may vary widely from one run to another, which makes the results across different runs noisy to compare.
|
||||
|
||||
## Remote tests
|
||||
|
||||
There are a few tests that marked with `pytest.mark.remote_cluster`. These tests do not set up a local environment, and instead require a libpq connection string to connect to. So they can be run on any Postgres compatible database. Currently, the CI runs these tests on our staging and captest environments daily. Those are not an isolated environments, so there can be noise in the results due to activity of other clusters.
|
||||
|
||||
## Noise
|
||||
|
||||
All tests run only once. Usually to obtain more consistent performance numbers, a test should be repeated multiple times and the results be aggregated, for example by taking min, max, avg, or median.
|
||||
|
||||
## Results collection
|
||||
|
||||
Local test results for main branch, and results of daily performance tests, are stored in a neon project deployed in production environment. There is a Grafana dashboard that visualizes the results. Here is the [dashboard](https://observer.zenith.tech/d/DGKBm9Jnz/perf-test-results?orgId=1). The main problem with it is the unavailability to point at particular commit, though the data for that is available in the database. Needs some tweaking from someone who knows Grafana tricks.
|
||||
|
||||
There is also an inconsistency in test naming. Test name should be the same across platforms, and results can be differentiated by the platform field. But currently, platform is sometimes included in test name because of the way how parametrization works in pytest. I.e. there is a platform switch in the dashboard with neon-local-ci and neon-staging variants. I.e. some tests under neon-local-ci value for a platform switch are displayed as `Test test_runner/performance/test_bulk_insert.py::test_bulk_insert[vanilla]` and `Test test_runner/performance/test_bulk_insert.py::test_bulk_insert[neon]` which is highly confusing.
|
||||
152
test_runner/performance/test_branch_creation.py
Normal file
152
test_runner/performance/test_branch_creation.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import random
|
||||
import statistics
|
||||
import threading
|
||||
import time
|
||||
import timeit
|
||||
from contextlib import closing
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.compare_fixtures import NeonCompare
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import wait_for_last_record_lsn
|
||||
from fixtures.types import Lsn
|
||||
|
||||
|
||||
def _record_branch_creation_durations(neon_compare: NeonCompare, durs: List[float]):
|
||||
neon_compare.zenbenchmark.record(
|
||||
"branch_creation_duration_max", max(durs), "s", MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
neon_compare.zenbenchmark.record(
|
||||
"branch_creation_duration_avg", statistics.mean(durs), "s", MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
neon_compare.zenbenchmark.record(
|
||||
"branch_creation_duration_stdev", statistics.stdev(durs), "s", MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_branches", [20])
|
||||
# Test measures the latency of branch creation during a heavy [1] workload.
|
||||
#
|
||||
# [1]: to simulate a heavy workload, the test tweaks the GC and compaction settings
|
||||
# to increase the task's frequency. The test runs `pgbench` in each new branch.
|
||||
# Each branch is created from a randomly picked source branch.
|
||||
def test_branch_creation_heavy_write(neon_compare: NeonCompare, n_branches: int):
|
||||
env = neon_compare.env
|
||||
pg_bin = neon_compare.pg_bin
|
||||
|
||||
# Use aggressive GC and checkpoint settings, so GC and compaction happen more often during the test
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
"gc_period": "5 s",
|
||||
"gc_horizon": f"{4 * 1024 ** 2}",
|
||||
"checkpoint_distance": f"{2 * 1024 ** 2}",
|
||||
"compaction_target_size": f"{1024 ** 2}",
|
||||
"compaction_threshold": "2",
|
||||
# set PITR interval to be small, so we can do GC
|
||||
"pitr_interval": "5 s",
|
||||
}
|
||||
)
|
||||
|
||||
def run_pgbench(branch: str):
|
||||
log.info(f"Start a pgbench workload on branch {branch}")
|
||||
|
||||
pg = env.postgres.create_start(branch, tenant_id=tenant)
|
||||
connstr = pg.connstr()
|
||||
|
||||
pg_bin.run_capture(["pgbench", "-i", connstr])
|
||||
pg_bin.run_capture(["pgbench", "-c10", "-T10", connstr])
|
||||
|
||||
pg.stop()
|
||||
|
||||
env.neon_cli.create_branch("b0", tenant_id=tenant)
|
||||
|
||||
threads: List[threading.Thread] = []
|
||||
threads.append(threading.Thread(target=run_pgbench, args=("b0",), daemon=True))
|
||||
threads[-1].start()
|
||||
|
||||
branch_creation_durations = []
|
||||
for i in range(n_branches):
|
||||
time.sleep(1.0)
|
||||
|
||||
# random a source branch
|
||||
p = random.randint(0, i)
|
||||
|
||||
timer = timeit.default_timer()
|
||||
env.neon_cli.create_branch("b{}".format(i + 1), "b{}".format(p), tenant_id=tenant)
|
||||
dur = timeit.default_timer() - timer
|
||||
|
||||
log.info(f"Creating branch b{i+1} took {dur}s")
|
||||
branch_creation_durations.append(dur)
|
||||
|
||||
threads.append(threading.Thread(target=run_pgbench, args=(f"b{i+1}",), daemon=True))
|
||||
threads[-1].start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
_record_branch_creation_durations(neon_compare, branch_creation_durations)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_branches", [1024])
|
||||
# Test measures the latency of branch creation when creating a lot of branches.
|
||||
def test_branch_creation_many(neon_compare: NeonCompare, n_branches: int):
|
||||
env = neon_compare.env
|
||||
|
||||
env.neon_cli.create_branch("b0")
|
||||
|
||||
pg = env.postgres.create_start("b0")
|
||||
neon_compare.pg_bin.run_capture(["pgbench", "-i", "-s10", pg.connstr()])
|
||||
|
||||
branch_creation_durations = []
|
||||
|
||||
for i in range(n_branches):
|
||||
# random a source branch
|
||||
p = random.randint(0, i)
|
||||
timer = timeit.default_timer()
|
||||
env.neon_cli.create_branch("b{}".format(i + 1), "b{}".format(p))
|
||||
dur = timeit.default_timer() - timer
|
||||
branch_creation_durations.append(dur)
|
||||
|
||||
_record_branch_creation_durations(neon_compare, branch_creation_durations)
|
||||
|
||||
|
||||
# Test measures the branch creation time when branching from a timeline with a lot of relations.
|
||||
#
|
||||
# This test measures the latency of branch creation under two scenarios
|
||||
# 1. The ancestor branch is not under any workloads
|
||||
# 2. The ancestor branch is under a workload (busy)
|
||||
#
|
||||
# To simulate the workload, the test runs a concurrent insertion on the ancestor branch right before branching.
|
||||
def test_branch_creation_many_relations(neon_compare: NeonCompare):
|
||||
env = neon_compare.env
|
||||
|
||||
timeline_id = env.neon_cli.create_branch("root")
|
||||
|
||||
pg = env.postgres.create_start("root")
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(10000):
|
||||
cur.execute(f"CREATE TABLE t{i} as SELECT g FROM generate_series(1, 1000) g")
|
||||
|
||||
# Wait for the pageserver to finish processing all the pending WALs,
|
||||
# as we don't want the LSN wait time to be included during the branch creation
|
||||
flush_lsn = Lsn(pg.safe_psql("SELECT pg_current_wal_flush_lsn()")[0][0])
|
||||
wait_for_last_record_lsn(
|
||||
env.pageserver.http_client(), env.initial_tenant, timeline_id, flush_lsn
|
||||
)
|
||||
|
||||
with neon_compare.record_duration("create_branch_time_not_busy_root"):
|
||||
env.neon_cli.create_branch("child_not_busy", "root")
|
||||
|
||||
# run a concurrent insertion to make the ancestor "busy" during the branch creation
|
||||
thread = threading.Thread(
|
||||
target=pg.safe_psql, args=("INSERT INTO t0 VALUES (generate_series(1, 100000))",)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
with neon_compare.record_duration("create_branch_time_busy_root"):
|
||||
env.neon_cli.create_branch("child_busy", "root")
|
||||
|
||||
thread.join()
|
||||
96
test_runner/performance/test_branching.py
Normal file
96
test_runner/performance/test_branching.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import timeit
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fixtures.benchmark_fixture import PgBenchRunResult
|
||||
from fixtures.compare_fixtures import NeonCompare
|
||||
from fixtures.neon_fixtures import fork_at_current_lsn
|
||||
from performance.test_perf_pgbench import utc_now_timestamp
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Start of `test_compare_child_and_root_*` tests
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# `test_compare_child_and_root_*` tests compare the performance of a branch and its child branch(s).
|
||||
# A common pattern in those tests is initializing a root branch then creating a child branch(s) from the root.
|
||||
# Each test then runs a similar workload for both child branch and root branch. Each measures and reports
|
||||
# some latencies/metrics during the workload for performance comparison between a branch and its ancestor.
|
||||
|
||||
|
||||
def test_compare_child_and_root_pgbench_perf(neon_compare: NeonCompare):
|
||||
env = neon_compare.env
|
||||
pg_bin = neon_compare.pg_bin
|
||||
|
||||
def run_pgbench_on_branch(branch: str, cmd: List[str]):
|
||||
run_start_timestamp = utc_now_timestamp()
|
||||
t0 = timeit.default_timer()
|
||||
out = pg_bin.run_capture(
|
||||
cmd,
|
||||
)
|
||||
run_duration = timeit.default_timer() - t0
|
||||
run_end_timestamp = utc_now_timestamp()
|
||||
|
||||
stdout = Path(f"{out}.stdout").read_text()
|
||||
|
||||
res = PgBenchRunResult.parse_from_stdout(
|
||||
stdout=stdout,
|
||||
run_duration=run_duration,
|
||||
run_start_timestamp=run_start_timestamp,
|
||||
run_end_timestamp=run_end_timestamp,
|
||||
)
|
||||
neon_compare.zenbenchmark.record_pg_bench_result(branch, res)
|
||||
|
||||
env.neon_cli.create_branch("root")
|
||||
pg_root = env.postgres.create_start("root")
|
||||
pg_bin.run_capture(["pgbench", "-i", pg_root.connstr(), "-s10"])
|
||||
|
||||
fork_at_current_lsn(env, pg_root, "child", "root")
|
||||
|
||||
pg_child = env.postgres.create_start("child")
|
||||
|
||||
run_pgbench_on_branch("root", ["pgbench", "-c10", "-T10", pg_root.connstr()])
|
||||
run_pgbench_on_branch("child", ["pgbench", "-c10", "-T10", pg_child.connstr()])
|
||||
|
||||
|
||||
def test_compare_child_and_root_write_perf(neon_compare: NeonCompare):
|
||||
env = neon_compare.env
|
||||
env.neon_cli.create_branch("root")
|
||||
pg_root = env.postgres.create_start("root")
|
||||
|
||||
pg_root.safe_psql(
|
||||
"CREATE TABLE foo(key serial primary key, t text default 'foooooooooooooooooooooooooooooooooooooooooooooooooooo')",
|
||||
)
|
||||
|
||||
env.neon_cli.create_branch("child", "root")
|
||||
pg_child = env.postgres.create_start("child")
|
||||
|
||||
with neon_compare.record_duration("root_run_duration"):
|
||||
pg_root.safe_psql("INSERT INTO foo SELECT FROM generate_series(1,1000000)")
|
||||
with neon_compare.record_duration("child_run_duration"):
|
||||
pg_child.safe_psql("INSERT INTO foo SELECT FROM generate_series(1,1000000)")
|
||||
|
||||
|
||||
def test_compare_child_and_root_read_perf(neon_compare: NeonCompare):
|
||||
env = neon_compare.env
|
||||
env.neon_cli.create_branch("root")
|
||||
pg_root = env.postgres.create_start("root")
|
||||
|
||||
pg_root.safe_psql_many(
|
||||
[
|
||||
"CREATE TABLE foo(key serial primary key, t text default 'foooooooooooooooooooooooooooooooooooooooooooooooooooo')",
|
||||
"INSERT INTO foo SELECT FROM generate_series(1,1000000)",
|
||||
]
|
||||
)
|
||||
|
||||
env.neon_cli.create_branch("child", "root")
|
||||
pg_child = env.postgres.create_start("child")
|
||||
|
||||
with neon_compare.record_duration("root_run_duration"):
|
||||
pg_root.safe_psql("SELECT count(*) from foo")
|
||||
with neon_compare.record_duration("child_run_duration"):
|
||||
pg_child.safe_psql("SELECT count(*) from foo")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# End of `test_compare_child_and_root_*` tests
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -1,8 +1,6 @@
|
||||
from contextlib import closing
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.benchmark_fixture import MetricReport, ZenithBenchmarker
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
|
||||
|
||||
#
|
||||
@@ -15,17 +13,16 @@ from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
# 3. Disk space used
|
||||
# 4. Peak memory usage
|
||||
#
|
||||
def test_bulk_insert(zenith_with_baseline: PgCompare):
|
||||
env = zenith_with_baseline
|
||||
def test_bulk_insert(neon_with_baseline: PgCompare):
|
||||
env = neon_with_baseline
|
||||
|
||||
# Get the timeline ID of our branch. We need it for the 'do_gc' command
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("create table huge (i int, j int);")
|
||||
|
||||
# Run INSERT, recording the time and I/O it takes
|
||||
with env.record_pageserver_writes('pageserver_writes'):
|
||||
with env.record_duration('insert'):
|
||||
with env.record_pageserver_writes("pageserver_writes"):
|
||||
with env.record_duration("insert"):
|
||||
cur.execute("insert into huge values (generate_series(1, 5000000), 0);")
|
||||
env.flush()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import timeit
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
import pytest
|
||||
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
import pytest
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder
|
||||
|
||||
# Run bulk tenant creation test.
|
||||
#
|
||||
@@ -12,38 +12,31 @@ from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
# 2. Average creation time per tenant
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tenants_count', [1, 5, 10])
|
||||
@pytest.mark.parametrize('use_wal_acceptors', ['with_wa', 'without_wa'])
|
||||
@pytest.mark.parametrize("tenants_count", [1, 5, 10])
|
||||
def test_bulk_tenant_create(
|
||||
zenith_env_builder: ZenithEnvBuilder,
|
||||
use_wal_acceptors: str,
|
||||
neon_env_builder: NeonEnvBuilder,
|
||||
tenants_count: int,
|
||||
zenbenchmark,
|
||||
):
|
||||
"""Measure tenant creation time (with and without wal acceptors)"""
|
||||
if use_wal_acceptors == 'with_wa':
|
||||
zenith_env_builder.num_safekeepers = 3
|
||||
env = zenith_env_builder.init()
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
time_slices = []
|
||||
|
||||
for i in range(tenants_count):
|
||||
start = timeit.default_timer()
|
||||
|
||||
tenant = env.create_tenant()
|
||||
env.zenith_cli.create_branch(
|
||||
f"test_bulk_tenant_create_{tenants_count}_{i}_{use_wal_acceptors}",
|
||||
"main",
|
||||
tenant_id=tenant)
|
||||
tenant, _ = env.neon_cli.create_tenant()
|
||||
env.neon_cli.create_timeline(
|
||||
f"test_bulk_tenant_create_{tenants_count}_{i}", tenant_id=tenant
|
||||
)
|
||||
|
||||
# FIXME: We used to start new safekeepers here. Did that make sense? Should we do it now?
|
||||
#if use_wal_acceptors == 'with_wa':
|
||||
# if use_safekeepers == 'with_sa':
|
||||
# wa_factory.start_n_new(3)
|
||||
|
||||
pg_tenant = env.postgres.create_start(
|
||||
f"test_bulk_tenant_create_{tenants_count}_{i}_{use_wal_acceptors}",
|
||||
None, # branch name, None means same as node name
|
||||
tenant,
|
||||
f"test_bulk_tenant_create_{tenants_count}_{i}", tenant_id=tenant
|
||||
)
|
||||
|
||||
end = timeit.default_timer()
|
||||
@@ -51,7 +44,9 @@ def test_bulk_tenant_create(
|
||||
|
||||
pg_tenant.stop()
|
||||
|
||||
zenbenchmark.record('tenant_creation_time',
|
||||
sum(time_slices) / len(time_slices),
|
||||
's',
|
||||
report=MetricReport.LOWER_IS_BETTER)
|
||||
zenbenchmark.record(
|
||||
"tenant_creation_time",
|
||||
sum(time_slices) / len(time_slices),
|
||||
"s",
|
||||
report=MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
|
||||
131
test_runner/performance/test_compare_pg_stats.py
Normal file
131
test_runner/performance/test_compare_pg_stats.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from fixtures.pg_stats import PgStatTable
|
||||
from performance.test_perf_pgbench import get_durations_matrix, get_scales_matrix
|
||||
|
||||
|
||||
def get_seeds_matrix(default: int = 100):
|
||||
seeds = os.getenv("TEST_PG_BENCH_SEEDS_MATRIX", default=str(default))
|
||||
return list(map(int, seeds.split(",")))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", get_seeds_matrix())
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix(5))
|
||||
def test_compare_pg_stats_rw_with_pgbench_default(
|
||||
neon_with_baseline: PgCompare,
|
||||
seed: int,
|
||||
scale: int,
|
||||
duration: int,
|
||||
pg_stats_rw: List[PgStatTable],
|
||||
):
|
||||
env = neon_with_baseline
|
||||
# initialize pgbench
|
||||
env.pg_bin.run_capture(["pgbench", f"-s{scale}", "-i", env.pg.connstr()])
|
||||
env.flush()
|
||||
|
||||
with env.record_pg_stats(pg_stats_rw):
|
||||
env.pg_bin.run_capture(
|
||||
["pgbench", f"-T{duration}", f"--random-seed={seed}", env.pg.connstr()]
|
||||
)
|
||||
env.flush()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", get_seeds_matrix())
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix(5))
|
||||
def test_compare_pg_stats_wo_with_pgbench_simple_update(
|
||||
neon_with_baseline: PgCompare,
|
||||
seed: int,
|
||||
scale: int,
|
||||
duration: int,
|
||||
pg_stats_wo: List[PgStatTable],
|
||||
):
|
||||
env = neon_with_baseline
|
||||
# initialize pgbench
|
||||
env.pg_bin.run_capture(["pgbench", f"-s{scale}", "-i", env.pg.connstr()])
|
||||
env.flush()
|
||||
|
||||
with env.record_pg_stats(pg_stats_wo):
|
||||
env.pg_bin.run_capture(
|
||||
["pgbench", "-N", f"-T{duration}", f"--random-seed={seed}", env.pg.connstr()]
|
||||
)
|
||||
env.flush()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", get_seeds_matrix())
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix(5))
|
||||
def test_compare_pg_stats_ro_with_pgbench_select_only(
|
||||
neon_with_baseline: PgCompare,
|
||||
seed: int,
|
||||
scale: int,
|
||||
duration: int,
|
||||
pg_stats_ro: List[PgStatTable],
|
||||
):
|
||||
env = neon_with_baseline
|
||||
# initialize pgbench
|
||||
env.pg_bin.run_capture(["pgbench", f"-s{scale}", "-i", env.pg.connstr()])
|
||||
env.flush()
|
||||
|
||||
with env.record_pg_stats(pg_stats_ro):
|
||||
env.pg_bin.run_capture(
|
||||
["pgbench", "-S", f"-T{duration}", f"--random-seed={seed}", env.pg.connstr()]
|
||||
)
|
||||
env.flush()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", get_seeds_matrix())
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix(5))
|
||||
def test_compare_pg_stats_wal_with_pgbench_default(
|
||||
neon_with_baseline: PgCompare,
|
||||
seed: int,
|
||||
scale: int,
|
||||
duration: int,
|
||||
pg_stats_wal: List[PgStatTable],
|
||||
):
|
||||
env = neon_with_baseline
|
||||
# initialize pgbench
|
||||
env.pg_bin.run_capture(["pgbench", f"-s{scale}", "-i", env.pg.connstr()])
|
||||
env.flush()
|
||||
|
||||
with env.record_pg_stats(pg_stats_wal):
|
||||
env.pg_bin.run_capture(
|
||||
["pgbench", f"-T{duration}", f"--random-seed={seed}", env.pg.connstr()]
|
||||
)
|
||||
env.flush()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_tables", [1, 10])
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix(10))
|
||||
def test_compare_pg_stats_wo_with_heavy_write(
|
||||
neon_with_baseline: PgCompare, n_tables: int, duration: int, pg_stats_wo: List[PgStatTable]
|
||||
):
|
||||
env = neon_with_baseline
|
||||
with env.pg.connect().cursor() as cur:
|
||||
for i in range(n_tables):
|
||||
cur.execute(
|
||||
f"CREATE TABLE t{i}(key serial primary key, t text default 'foooooooooooooooooooooooooooooooooooooooooooooooooooo')"
|
||||
)
|
||||
|
||||
def start_single_table_workload(table_id: int):
|
||||
start = time.time()
|
||||
with env.pg.connect().cursor() as cur:
|
||||
while time.time() - start < duration:
|
||||
cur.execute(f"INSERT INTO t{table_id} SELECT FROM generate_series(1,1000)")
|
||||
|
||||
with env.record_pg_stats(pg_stats_wo):
|
||||
threads = [
|
||||
threading.Thread(target=start_single_table_workload, args=(i,)) for i in range(n_tables)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
@@ -1,10 +1,7 @@
|
||||
from contextlib import closing
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.benchmark_fixture import MetricReport, ZenithBenchmarker
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
from io import BufferedReader, RawIOBase
|
||||
from itertools import repeat
|
||||
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
|
||||
|
||||
class CopyTestData(RawIOBase):
|
||||
@@ -27,9 +24,9 @@ class CopyTestData(RawIOBase):
|
||||
self.rownum += 1
|
||||
|
||||
# Number of bytes to read in this call
|
||||
l = min(len(self.linebuf) - self.ptr, len(b))
|
||||
l = min(len(self.linebuf) - self.ptr, len(b)) # noqa: E741
|
||||
|
||||
b[:l] = self.linebuf[self.ptr:(self.ptr + l)]
|
||||
b[:l] = self.linebuf[self.ptr : (self.ptr + l)]
|
||||
self.ptr += l
|
||||
return l
|
||||
|
||||
@@ -41,8 +38,8 @@ def copy_test_data(rows: int):
|
||||
#
|
||||
# COPY performance tests.
|
||||
#
|
||||
def test_copy(zenith_with_baseline: PgCompare):
|
||||
env = zenith_with_baseline
|
||||
def test_copy(neon_with_baseline: PgCompare):
|
||||
env = neon_with_baseline
|
||||
|
||||
# Get the timeline ID of our branch. We need it for the pageserver 'checkpoint' command
|
||||
with closing(env.pg.connect()) as conn:
|
||||
@@ -52,19 +49,19 @@ def test_copy(zenith_with_baseline: PgCompare):
|
||||
# Load data with COPY, recording the time and I/O it takes.
|
||||
#
|
||||
# Since there's no data in the table previously, this extends it.
|
||||
with env.record_pageserver_writes('copy_extend_pageserver_writes'):
|
||||
with env.record_duration('copy_extend'):
|
||||
cur.copy_from(copy_test_data(1000000), 'copytest')
|
||||
with env.record_pageserver_writes("copy_extend_pageserver_writes"):
|
||||
with env.record_duration("copy_extend"):
|
||||
cur.copy_from(copy_test_data(1000000), "copytest")
|
||||
env.flush()
|
||||
|
||||
# Delete most rows, and VACUUM to make the space available for reuse.
|
||||
with env.record_pageserver_writes('delete_pageserver_writes'):
|
||||
with env.record_duration('delete'):
|
||||
with env.record_pageserver_writes("delete_pageserver_writes"):
|
||||
with env.record_duration("delete"):
|
||||
cur.execute("delete from copytest where i % 100 <> 0;")
|
||||
env.flush()
|
||||
|
||||
with env.record_pageserver_writes('vacuum_pageserver_writes'):
|
||||
with env.record_duration('vacuum'):
|
||||
with env.record_pageserver_writes("vacuum_pageserver_writes"):
|
||||
with env.record_duration("vacuum"):
|
||||
cur.execute("vacuum copytest")
|
||||
env.flush()
|
||||
|
||||
@@ -72,9 +69,9 @@ def test_copy(zenith_with_baseline: PgCompare):
|
||||
# by the VACUUM.
|
||||
#
|
||||
# This will also clear all the VM bits.
|
||||
with env.record_pageserver_writes('copy_reuse_pageserver_writes'):
|
||||
with env.record_duration('copy_reuse'):
|
||||
cur.copy_from(copy_test_data(1000000), 'copytest')
|
||||
with env.record_pageserver_writes("copy_reuse_pageserver_writes"):
|
||||
with env.record_duration("copy_reuse"):
|
||||
cur.copy_from(copy_test_data(1000000), "copytest")
|
||||
env.flush()
|
||||
|
||||
env.report_peak_memory_use()
|
||||
|
||||
54
test_runner/performance/test_dup_key.py
Normal file
54
test_runner/performance/test_dup_key.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from pytest_lazyfixture import lazy_fixture # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env",
|
||||
[
|
||||
# The test is too slow to run in CI, but fast enough to run with remote tests
|
||||
pytest.param(lazy_fixture("neon_compare"), id="neon", marks=pytest.mark.slow),
|
||||
pytest.param(lazy_fixture("vanilla_compare"), id="vanilla", marks=pytest.mark.slow),
|
||||
pytest.param(lazy_fixture("remote_compare"), id="remote", marks=pytest.mark.remote_cluster),
|
||||
],
|
||||
)
|
||||
def test_dup_key(env: PgCompare):
|
||||
# Update the same page many times, then measure read performance
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("drop table if exists t, f;")
|
||||
|
||||
cur.execute("SET synchronous_commit=off")
|
||||
cur.execute("SET statement_timeout=0")
|
||||
|
||||
# Write many updates to the same row
|
||||
with env.record_duration("write"):
|
||||
cur.execute("create table t (i integer, filler text);")
|
||||
cur.execute("insert into t values (0);")
|
||||
cur.execute(
|
||||
"""
|
||||
do $$
|
||||
begin
|
||||
for ivar in 1..5000000 loop
|
||||
update t set i = ivar, filler = repeat('a', 50);
|
||||
update t set i = ivar, filler = repeat('b', 50);
|
||||
update t set i = ivar, filler = repeat('c', 50);
|
||||
update t set i = ivar, filler = repeat('d', 50);
|
||||
rollback;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Write 3-4 MB to evict t from compute cache
|
||||
cur.execute("create table f (i integer);")
|
||||
cur.execute("insert into f values (generate_series(1,100000));")
|
||||
|
||||
# Read
|
||||
with env.record_duration("read"):
|
||||
cur.execute("select * from t;")
|
||||
cur.fetchall()
|
||||
@@ -1,9 +1,6 @@
|
||||
import os
|
||||
from contextlib import closing
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
from fixtures.log_helper import log
|
||||
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
|
||||
|
||||
#
|
||||
@@ -11,8 +8,8 @@ from fixtures.log_helper import log
|
||||
# As of this writing, we're duplicate those giant WAL records for each page,
|
||||
# which makes the delta layer about 32x larger than it needs to be.
|
||||
#
|
||||
def test_gist_buffering_build(zenith_with_baseline: PgCompare):
|
||||
env = zenith_with_baseline
|
||||
def test_gist_buffering_build(neon_with_baseline: PgCompare):
|
||||
env = neon_with_baseline
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -24,8 +21,8 @@ def test_gist_buffering_build(zenith_with_baseline: PgCompare):
|
||||
)
|
||||
|
||||
# Build the index.
|
||||
with env.record_pageserver_writes('pageserver_writes'):
|
||||
with env.record_duration('build'):
|
||||
with env.record_pageserver_writes("pageserver_writes"):
|
||||
with env.record_duration("build"):
|
||||
cur.execute(
|
||||
"create index gist_pointidx2 on gist_point_tbl using gist(p) with (buffering = on)"
|
||||
)
|
||||
|
||||
39
test_runner/performance/test_hot_page.py
Normal file
39
test_runner/performance/test_hot_page.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from pytest_lazyfixture import lazy_fixture # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env",
|
||||
[
|
||||
# The test is too slow to run in CI, but fast enough to run with remote tests
|
||||
pytest.param(lazy_fixture("neon_compare"), id="neon", marks=pytest.mark.slow),
|
||||
pytest.param(lazy_fixture("vanilla_compare"), id="vanilla", marks=pytest.mark.slow),
|
||||
pytest.param(lazy_fixture("remote_compare"), id="remote", marks=pytest.mark.remote_cluster),
|
||||
],
|
||||
)
|
||||
def test_hot_page(env: PgCompare):
|
||||
# Update the same page many times, then measure read performance
|
||||
num_writes = 1000000
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("drop table if exists t, f;")
|
||||
|
||||
# Write many updates to the same row
|
||||
with env.record_duration("write"):
|
||||
cur.execute("create table t (i integer);")
|
||||
cur.execute("insert into t values (0);")
|
||||
for i in range(num_writes):
|
||||
cur.execute(f"update t set i = {i};")
|
||||
|
||||
# Write 3-4 MB to evict t from compute cache
|
||||
cur.execute("create table f (i integer);")
|
||||
cur.execute("insert into f values (generate_series(1,100000));")
|
||||
|
||||
# Read
|
||||
with env.record_duration("read"):
|
||||
cur.execute("select * from t;")
|
||||
cur.fetchall()
|
||||
38
test_runner/performance/test_hot_table.py
Normal file
38
test_runner/performance/test_hot_table.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from pytest_lazyfixture import lazy_fixture # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env",
|
||||
[
|
||||
# The test is too slow to run in CI, but fast enough to run with remote tests
|
||||
pytest.param(lazy_fixture("neon_compare"), id="neon", marks=pytest.mark.slow),
|
||||
pytest.param(lazy_fixture("vanilla_compare"), id="vanilla", marks=pytest.mark.slow),
|
||||
pytest.param(lazy_fixture("remote_compare"), id="remote", marks=pytest.mark.remote_cluster),
|
||||
],
|
||||
)
|
||||
def test_hot_table(env: PgCompare):
|
||||
# Update a small table many times, then measure read performance
|
||||
num_rows = 100000 # Slightly larger than shared buffers size TODO validate
|
||||
num_writes = 1000000
|
||||
num_reads = 10
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("drop table if exists t;")
|
||||
|
||||
# Write many updates to a small table
|
||||
with env.record_duration("write"):
|
||||
cur.execute("create table t (i integer primary key);")
|
||||
cur.execute(f"insert into t values (generate_series(1,{num_rows}));")
|
||||
for i in range(num_writes):
|
||||
cur.execute(f"update t set i = {i + num_rows} WHERE i = {i};")
|
||||
|
||||
# Read the table
|
||||
with env.record_duration("read"):
|
||||
for i in range(num_reads):
|
||||
cur.execute("select * from t;")
|
||||
cur.fetchall()
|
||||
29
test_runner/performance/test_latency.py
Normal file
29
test_runner/performance/test_latency.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from fixtures.neon_fixtures import Postgres
|
||||
from performance.test_perf_pgbench import get_scales_matrix
|
||||
from performance.test_wal_backpressure import record_read_latency
|
||||
|
||||
|
||||
def start_write_workload(pg: Postgres, scale: int = 10):
|
||||
with pg.connect().cursor() as cur:
|
||||
cur.execute(f"create table big as select generate_series(1,{scale*100_000})")
|
||||
|
||||
|
||||
# Measure latency of reads on one table, while lots of writes are happening on another table.
|
||||
# The fine-grained tracking of last-written LSNs helps to keep the latency low. Without it, the reads would
|
||||
# often need to wait for the WAL records of the unrelated writes to be processed by the pageserver.
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix(1))
|
||||
def test_measure_read_latency_heavy_write_workload(neon_with_baseline: PgCompare, scale: int):
|
||||
env = neon_with_baseline
|
||||
pg = env.pg
|
||||
|
||||
with pg.connect().cursor() as cur:
|
||||
cur.execute(f"create table small as select generate_series(1,{scale*100_000})")
|
||||
|
||||
write_thread = threading.Thread(target=start_write_workload, args=(pg, scale * 100))
|
||||
write_thread.start()
|
||||
|
||||
record_read_latency(env, lambda: write_thread.is_alive(), "SELECT count(*) from small")
|
||||
39
test_runner/performance/test_layer_map.py
Normal file
39
test_runner/performance/test_layer_map.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import time
|
||||
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder
|
||||
|
||||
|
||||
#
|
||||
# Benchmark searching the layer map, when there are a lot of small layer files.
|
||||
#
|
||||
def test_layer_map(neon_env_builder: NeonEnvBuilder, zenbenchmark):
|
||||
|
||||
env = neon_env_builder.init_start()
|
||||
n_iters = 10
|
||||
n_records = 100000
|
||||
|
||||
# We want to have a lot of lot of layer files to exercise the layer map. Make
|
||||
# gc_horizon and checkpoint_distance very small, so that we get a lot of small layer files.
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
"gc_period": "100 m",
|
||||
"gc_horizon": "1048576",
|
||||
"checkpoint_distance": "8192",
|
||||
"compaction_period": "1 s",
|
||||
"compaction_threshold": "1",
|
||||
"compaction_target_size": "8192",
|
||||
}
|
||||
)
|
||||
|
||||
env.neon_cli.create_timeline("test_layer_map", tenant_id=tenant)
|
||||
pg = env.postgres.create_start("test_layer_map", tenant_id=tenant)
|
||||
cur = pg.connect().cursor()
|
||||
cur.execute("create table t(x integer)")
|
||||
for i in range(n_iters):
|
||||
cur.execute(f"insert into t values (generate_series(1,{n_records}))")
|
||||
time.sleep(1)
|
||||
|
||||
cur.execute("vacuum t")
|
||||
with zenbenchmark.record_duration("test_query"):
|
||||
cur.execute("SELECT count(*) from t")
|
||||
assert cur.fetchone() == (n_iters * n_records,)
|
||||
@@ -1,10 +1,8 @@
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from fixtures.zenith_fixtures import ZenithEnv, Postgres, PgProtocol
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.benchmark_fixture import MetricReport, ZenithBenchmarker
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
from io import BytesIO
|
||||
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from fixtures.neon_fixtures import PgProtocol
|
||||
|
||||
|
||||
async def repeat_bytes(buf, repetitions: int):
|
||||
@@ -16,7 +14,8 @@ async def copy_test_data_to_table(pg: PgProtocol, worker_id: int, table_name: st
|
||||
buf = BytesIO()
|
||||
for i in range(1000):
|
||||
buf.write(
|
||||
f"{i}\tLoaded by worker {worker_id}. Long string to consume some space.\n".encode())
|
||||
f"{i}\tLoaded by worker {worker_id}. Long string to consume some space.\n".encode()
|
||||
)
|
||||
buf.seek(0)
|
||||
|
||||
copy_input = repeat_bytes(buf.read(), 5000)
|
||||
@@ -28,7 +27,7 @@ async def copy_test_data_to_table(pg: PgProtocol, worker_id: int, table_name: st
|
||||
async def parallel_load_different_tables(pg: PgProtocol, n_parallel: int):
|
||||
workers = []
|
||||
for worker_id in range(n_parallel):
|
||||
worker = copy_test_data_to_table(pg, worker_id, f'copytest_{worker_id}')
|
||||
worker = copy_test_data_to_table(pg, worker_id, f"copytest_{worker_id}")
|
||||
workers.append(asyncio.create_task(worker))
|
||||
|
||||
# await all workers
|
||||
@@ -36,17 +35,17 @@ async def parallel_load_different_tables(pg: PgProtocol, n_parallel: int):
|
||||
|
||||
|
||||
# Load 5 different tables in parallel with COPY TO
|
||||
def test_parallel_copy_different_tables(zenith_with_baseline: PgCompare, n_parallel=5):
|
||||
def test_parallel_copy_different_tables(neon_with_baseline: PgCompare, n_parallel=5):
|
||||
|
||||
env = zenith_with_baseline
|
||||
env = neon_with_baseline
|
||||
conn = env.pg.connect()
|
||||
cur = conn.cursor()
|
||||
|
||||
for worker_id in range(n_parallel):
|
||||
cur.execute(f'CREATE TABLE copytest_{worker_id} (i int, t text)')
|
||||
cur.execute(f"CREATE TABLE copytest_{worker_id} (i int, t text)")
|
||||
|
||||
with env.record_pageserver_writes('pageserver_writes'):
|
||||
with env.record_duration('load'):
|
||||
with env.record_pageserver_writes("pageserver_writes"):
|
||||
with env.record_duration("load"):
|
||||
asyncio.run(parallel_load_different_tables(env.pg, n_parallel))
|
||||
env.flush()
|
||||
|
||||
@@ -57,7 +56,7 @@ def test_parallel_copy_different_tables(zenith_with_baseline: PgCompare, n_paral
|
||||
async def parallel_load_same_table(pg: PgProtocol, n_parallel: int):
|
||||
workers = []
|
||||
for worker_id in range(n_parallel):
|
||||
worker = copy_test_data_to_table(pg, worker_id, f'copytest')
|
||||
worker = copy_test_data_to_table(pg, worker_id, "copytest")
|
||||
workers.append(asyncio.create_task(worker))
|
||||
|
||||
# await all workers
|
||||
@@ -65,15 +64,15 @@ async def parallel_load_same_table(pg: PgProtocol, n_parallel: int):
|
||||
|
||||
|
||||
# Load data into one table with COPY TO from 5 parallel connections
|
||||
def test_parallel_copy_same_table(zenith_with_baseline: PgCompare, n_parallel=5):
|
||||
env = zenith_with_baseline
|
||||
def test_parallel_copy_same_table(neon_with_baseline: PgCompare, n_parallel=5):
|
||||
env = neon_with_baseline
|
||||
conn = env.pg.connect()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f'CREATE TABLE copytest (i int, t text)')
|
||||
cur.execute("CREATE TABLE copytest (i int, t text)")
|
||||
|
||||
with env.record_pageserver_writes('pageserver_writes'):
|
||||
with env.record_duration('load'):
|
||||
with env.record_pageserver_writes("pageserver_writes"):
|
||||
with env.record_duration("load"):
|
||||
asyncio.run(parallel_load_same_table(env.pg, n_parallel))
|
||||
env.flush()
|
||||
|
||||
|
||||
@@ -1,30 +1,223 @@
|
||||
from contextlib import closing
|
||||
from fixtures.zenith_fixtures import PgBin, VanillaPostgres, ZenithEnv
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
import calendar
|
||||
import enum
|
||||
import os
|
||||
import timeit
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from fixtures.benchmark_fixture import MetricReport, ZenithBenchmarker
|
||||
from fixtures.log_helper import log
|
||||
import pytest
|
||||
from fixtures.benchmark_fixture import MetricReport, PgBenchInitResult, PgBenchRunResult
|
||||
from fixtures.compare_fixtures import NeonCompare, PgCompare
|
||||
from fixtures.utils import get_scale_for_db
|
||||
|
||||
|
||||
@enum.unique
|
||||
class PgBenchLoadType(enum.Enum):
|
||||
INIT = "init"
|
||||
SIMPLE_UPDATE = "simple_update"
|
||||
SELECT_ONLY = "select-only"
|
||||
|
||||
|
||||
def utc_now_timestamp() -> int:
|
||||
return calendar.timegm(datetime.utcnow().utctimetuple())
|
||||
|
||||
|
||||
def init_pgbench(env: PgCompare, cmdline, password: None):
|
||||
environ: Dict[str, str] = {}
|
||||
if password is not None:
|
||||
environ["PGPASSWORD"] = password
|
||||
|
||||
# calculate timestamps and durations separately
|
||||
# timestamp is intended to be used for linking to grafana and logs
|
||||
# duration is actually a metric and uses float instead of int for timestamp
|
||||
start_timestamp = utc_now_timestamp()
|
||||
t0 = timeit.default_timer()
|
||||
with env.record_pageserver_writes("init.pageserver_writes"):
|
||||
out = env.pg_bin.run_capture(cmdline, env=environ)
|
||||
env.flush()
|
||||
|
||||
duration = timeit.default_timer() - t0
|
||||
end_timestamp = utc_now_timestamp()
|
||||
|
||||
stderr = Path(f"{out}.stderr").read_text()
|
||||
|
||||
res = PgBenchInitResult.parse_from_stderr(
|
||||
stderr=stderr,
|
||||
duration=duration,
|
||||
start_timestamp=start_timestamp,
|
||||
end_timestamp=end_timestamp,
|
||||
)
|
||||
env.zenbenchmark.record_pg_bench_init_result("init", res)
|
||||
|
||||
|
||||
def run_pgbench(env: PgCompare, prefix: str, cmdline, password: None):
|
||||
environ: Dict[str, str] = {}
|
||||
if password is not None:
|
||||
environ["PGPASSWORD"] = password
|
||||
|
||||
with env.record_pageserver_writes(f"{prefix}.pageserver_writes"):
|
||||
run_start_timestamp = utc_now_timestamp()
|
||||
t0 = timeit.default_timer()
|
||||
out = env.pg_bin.run_capture(cmdline, env=environ)
|
||||
run_duration = timeit.default_timer() - t0
|
||||
run_end_timestamp = utc_now_timestamp()
|
||||
env.flush()
|
||||
|
||||
stdout = Path(f"{out}.stdout").read_text()
|
||||
|
||||
res = PgBenchRunResult.parse_from_stdout(
|
||||
stdout=stdout,
|
||||
run_duration=run_duration,
|
||||
run_start_timestamp=run_start_timestamp,
|
||||
run_end_timestamp=run_end_timestamp,
|
||||
)
|
||||
env.zenbenchmark.record_pg_bench_result(prefix, res)
|
||||
|
||||
|
||||
#
|
||||
# Run a very short pgbench test.
|
||||
# Initialize a pgbench database, and run pgbench against it.
|
||||
#
|
||||
# Collects three metrics:
|
||||
# This makes runs two different pgbench workloads against the same
|
||||
# initialized database, and 'duration' is the time of each run. So
|
||||
# the total runtime is 2 * duration, plus time needed to initialize
|
||||
# the test database.
|
||||
#
|
||||
# 1. Time to initialize the pgbench database (pgbench -s5 -i)
|
||||
# 2. Time to run 5000 pgbench transactions
|
||||
# 3. Disk space used
|
||||
#
|
||||
def test_pgbench(zenith_with_baseline: PgCompare):
|
||||
env = zenith_with_baseline
|
||||
# Currently, the # of connections is hardcoded at 4
|
||||
def run_test_pgbench(env: PgCompare, scale: int, duration: int, workload_type: PgBenchLoadType):
|
||||
env.zenbenchmark.record("scale", scale, "", MetricReport.TEST_PARAM)
|
||||
|
||||
with env.record_pageserver_writes('pageserver_writes'):
|
||||
with env.record_duration('init'):
|
||||
env.pg_bin.run_capture(['pgbench', '-s5', '-i', env.pg.connstr()])
|
||||
env.flush()
|
||||
password = env.pg.default_options.get("password", None)
|
||||
options = "-cstatement_timeout=1h " + env.pg.default_options.get("options", "")
|
||||
# drop password from the connection string by passing password=None and set password separately
|
||||
connstr = env.pg.connstr(password=None, options=options)
|
||||
|
||||
with env.record_duration('5000_xacts'):
|
||||
env.pg_bin.run_capture(['pgbench', '-c1', '-t5000', env.pg.connstr()])
|
||||
env.flush()
|
||||
if workload_type == PgBenchLoadType.INIT:
|
||||
# Run initialize
|
||||
init_pgbench(env, ["pgbench", f"-s{scale}", "-i", connstr], password=password)
|
||||
|
||||
if workload_type == PgBenchLoadType.SIMPLE_UPDATE:
|
||||
# Run simple-update workload
|
||||
run_pgbench(
|
||||
env,
|
||||
"simple-update",
|
||||
[
|
||||
"pgbench",
|
||||
"-N",
|
||||
"-c4",
|
||||
f"-T{duration}",
|
||||
"-P2",
|
||||
"--progress-timestamp",
|
||||
connstr,
|
||||
],
|
||||
password=password,
|
||||
)
|
||||
|
||||
if workload_type == PgBenchLoadType.SELECT_ONLY:
|
||||
# Run SELECT workload
|
||||
run_pgbench(
|
||||
env,
|
||||
"select-only",
|
||||
[
|
||||
"pgbench",
|
||||
"-S",
|
||||
"-c4",
|
||||
f"-T{duration}",
|
||||
"-P2",
|
||||
"--progress-timestamp",
|
||||
connstr,
|
||||
],
|
||||
password=password,
|
||||
)
|
||||
|
||||
env.report_size()
|
||||
|
||||
|
||||
def get_durations_matrix(default: int = 45) -> List[int]:
|
||||
durations = os.getenv("TEST_PG_BENCH_DURATIONS_MATRIX", default=str(default))
|
||||
rv = []
|
||||
for d in durations.split(","):
|
||||
d = d.strip().lower()
|
||||
if d.endswith("h"):
|
||||
duration = int(d.removesuffix("h")) * 60 * 60
|
||||
elif d.endswith("m"):
|
||||
duration = int(d.removesuffix("m")) * 60
|
||||
else:
|
||||
duration = int(d.removesuffix("s"))
|
||||
rv.append(duration)
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
def get_scales_matrix(default: int = 10) -> List[int]:
|
||||
scales = os.getenv("TEST_PG_BENCH_SCALES_MATRIX", default=str(default))
|
||||
rv = []
|
||||
for s in scales.split(","):
|
||||
s = s.strip().lower()
|
||||
if s.endswith("mb"):
|
||||
scale = get_scale_for_db(int(s.removesuffix("mb")))
|
||||
elif s.endswith("gb"):
|
||||
scale = get_scale_for_db(int(s.removesuffix("gb")) * 1024)
|
||||
else:
|
||||
scale = int(s)
|
||||
rv.append(scale)
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
# Run the pgbench tests against vanilla Postgres and neon
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix())
|
||||
def test_pgbench(neon_with_baseline: PgCompare, scale: int, duration: int):
|
||||
run_test_pgbench(neon_with_baseline, scale, duration, PgBenchLoadType.INIT)
|
||||
run_test_pgbench(neon_with_baseline, scale, duration, PgBenchLoadType.SIMPLE_UPDATE)
|
||||
run_test_pgbench(neon_with_baseline, scale, duration, PgBenchLoadType.SELECT_ONLY)
|
||||
|
||||
|
||||
# Run the pgbench tests, and generate a flamegraph from it
|
||||
# This requires that the pageserver was built with the 'profiling' feature.
|
||||
#
|
||||
# TODO: If the profiling is cheap enough, there's no need to run the same test
|
||||
# twice, with and without profiling. But for now, run it separately, so that we
|
||||
# can see how much overhead the profiling adds.
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix())
|
||||
def test_pgbench_flamegraph(zenbenchmark, pg_bin, neon_env_builder, scale: int, duration: int):
|
||||
neon_env_builder.pageserver_config_override = """
|
||||
profiling="page_requests"
|
||||
"""
|
||||
env = neon_env_builder.init_start()
|
||||
env.pageserver.is_profiling_enabled_or_skip()
|
||||
env.neon_cli.create_branch("empty", "main")
|
||||
|
||||
neon_compare = NeonCompare(zenbenchmark, env, pg_bin, "pgbench")
|
||||
run_test_pgbench(neon_compare, scale, duration, PgBenchLoadType.INIT)
|
||||
run_test_pgbench(neon_compare, scale, duration, PgBenchLoadType.SIMPLE_UPDATE)
|
||||
run_test_pgbench(neon_compare, scale, duration, PgBenchLoadType.SELECT_ONLY)
|
||||
|
||||
|
||||
# The following 3 tests run on an existing database as it was set up by previous tests,
|
||||
# and leaves the database in a state that would be used in the next tests.
|
||||
# Modifying the definition order of these functions or adding other remote tests in between will alter results.
|
||||
# See usage of --sparse-ordering flag in the pytest invocation in the CI workflow
|
||||
#
|
||||
# Run the pgbench tests against an existing Postgres cluster
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix())
|
||||
@pytest.mark.remote_cluster
|
||||
def test_pgbench_remote_init(remote_compare: PgCompare, scale: int, duration: int):
|
||||
run_test_pgbench(remote_compare, scale, duration, PgBenchLoadType.INIT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix())
|
||||
@pytest.mark.remote_cluster
|
||||
def test_pgbench_remote_simple_update(remote_compare: PgCompare, scale: int, duration: int):
|
||||
run_test_pgbench(remote_compare, scale, duration, PgBenchLoadType.SIMPLE_UPDATE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix())
|
||||
@pytest.mark.remote_cluster
|
||||
def test_pgbench_remote_select_only(remote_compare: PgCompare, scale: int, duration: int):
|
||||
run_test_pgbench(remote_compare, scale, duration, PgBenchLoadType.SELECT_ONLY)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
from typing import List
|
||||
from fixtures.benchmark_fixture import PgBenchRunResult, ZenithBenchmarker
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
import calendar
|
||||
import timeit
|
||||
import os
|
||||
|
||||
|
||||
def utc_now_timestamp() -> int:
|
||||
return calendar.timegm(datetime.utcnow().utctimetuple())
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PgBenchRunner:
|
||||
connstr: str
|
||||
scale: int
|
||||
transactions: int
|
||||
pgbench_bin_path: str = "pgbench"
|
||||
|
||||
def invoke(self, args: List[str]) -> 'subprocess.CompletedProcess[str]':
|
||||
res = subprocess.run([self.pgbench_bin_path, *args], text=True, capture_output=True)
|
||||
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"pgbench failed. stdout: {res.stdout} stderr: {res.stderr}")
|
||||
return res
|
||||
|
||||
def init(self, vacuum: bool = True) -> 'subprocess.CompletedProcess[str]':
|
||||
args = []
|
||||
if not vacuum:
|
||||
args.append("--no-vacuum")
|
||||
args.extend([f"--scale={self.scale}", "--initialize", self.connstr])
|
||||
return self.invoke(args)
|
||||
|
||||
def run(self, jobs: int = 1, clients: int = 1):
|
||||
return self.invoke([
|
||||
f"--transactions={self.transactions}",
|
||||
f"--jobs={jobs}",
|
||||
f"--client={clients}",
|
||||
"--progress=2", # print progress every two seconds
|
||||
self.connstr,
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connstr():
|
||||
res = os.getenv("BENCHMARK_CONNSTR")
|
||||
if res is None:
|
||||
raise ValueError("no connstr provided, use BENCHMARK_CONNSTR environment variable")
|
||||
return res
|
||||
|
||||
|
||||
def get_transactions_matrix():
|
||||
transactions = os.getenv("TEST_PG_BENCH_TRANSACTIONS_MATRIX")
|
||||
if transactions is None:
|
||||
return [10**4, 10**5]
|
||||
return list(map(int, transactions.split(",")))
|
||||
|
||||
|
||||
def get_scales_matrix():
|
||||
scales = os.getenv("TEST_PG_BENCH_SCALES_MATRIX")
|
||||
if scales is None:
|
||||
return [10, 20]
|
||||
return list(map(int, scales.split(",")))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix())
|
||||
@pytest.mark.parametrize("transactions", get_transactions_matrix())
|
||||
@pytest.mark.remote_cluster
|
||||
def test_pg_bench_remote_cluster(zenbenchmark: ZenithBenchmarker,
|
||||
connstr: str,
|
||||
scale: int,
|
||||
transactions: int):
|
||||
"""
|
||||
The best way is to run same pack of tests both, for local zenith
|
||||
and against staging, but currently local tests heavily depend on
|
||||
things available only locally e.g. zenith binaries, pageserver api, etc.
|
||||
Also separate test allows to run pgbench workload against vanilla postgres
|
||||
or other systems that support postgres protocol.
|
||||
|
||||
Also now this is more of a liveness test because it stresses pageserver internals,
|
||||
so we clearly see what goes wrong in more "real" environment.
|
||||
"""
|
||||
pg_bin = os.getenv("PG_BIN")
|
||||
if pg_bin is not None:
|
||||
pgbench_bin_path = os.path.join(pg_bin, "pgbench")
|
||||
else:
|
||||
pgbench_bin_path = "pgbench"
|
||||
|
||||
runner = PgBenchRunner(
|
||||
connstr=connstr,
|
||||
scale=scale,
|
||||
transactions=transactions,
|
||||
pgbench_bin_path=pgbench_bin_path,
|
||||
)
|
||||
# calculate timestamps and durations separately
|
||||
# timestamp is intended to be used for linking to grafana and logs
|
||||
# duration is actually a metric and uses float instead of int for timestamp
|
||||
init_start_timestamp = utc_now_timestamp()
|
||||
t0 = timeit.default_timer()
|
||||
runner.init()
|
||||
init_duration = timeit.default_timer() - t0
|
||||
init_end_timestamp = utc_now_timestamp()
|
||||
|
||||
run_start_timestamp = utc_now_timestamp()
|
||||
t0 = timeit.default_timer()
|
||||
out = runner.run() # TODO handle failures
|
||||
run_duration = timeit.default_timer() - t0
|
||||
run_end_timestamp = utc_now_timestamp()
|
||||
|
||||
res = PgBenchRunResult.parse_from_output(
|
||||
out=out,
|
||||
init_duration=init_duration,
|
||||
init_start_timestamp=init_start_timestamp,
|
||||
init_end_timestamp=init_end_timestamp,
|
||||
run_duration=run_duration,
|
||||
run_start_timestamp=run_start_timestamp,
|
||||
run_end_timestamp=run_end_timestamp,
|
||||
)
|
||||
|
||||
zenbenchmark.record_pg_bench_result(res)
|
||||
@@ -1,14 +1,9 @@
|
||||
import os
|
||||
from contextlib import closing
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
from fixtures.log_helper import log
|
||||
|
||||
import psycopg2.extras
|
||||
import random
|
||||
import time
|
||||
from fixtures.utils import print_gc_result
|
||||
from contextlib import closing
|
||||
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from fixtures.utils import query_scalar
|
||||
|
||||
|
||||
# This is a clear-box test that demonstrates the worst case scenario for the
|
||||
@@ -17,14 +12,14 @@ from fixtures.utils import print_gc_result
|
||||
# A naive pageserver implementation would create a full image layer for each
|
||||
# dirty segment, leading to write_amplification = segment_size / page_size,
|
||||
# when compared to vanilla postgres. With segment_size = 10MB, that's 1250.
|
||||
def test_random_writes(zenith_with_baseline: PgCompare):
|
||||
env = zenith_with_baseline
|
||||
def test_random_writes(neon_with_baseline: PgCompare):
|
||||
env = neon_with_baseline
|
||||
|
||||
# Number of rows in the test database. 1M rows runs quickly, but implies
|
||||
# a small effective_checkpoint_distance, which makes the test less realistic.
|
||||
# Using a 300 TB database would imply a 250 MB effective_checkpoint_distance,
|
||||
# but it will take a very long time to run. From what I've seen so far,
|
||||
# increasing n_rows doesn't have impact on the (zenith_runtime / vanilla_runtime)
|
||||
# increasing n_rows doesn't have impact on the (neon_runtime / vanilla_runtime)
|
||||
# performance ratio.
|
||||
n_rows = 1 * 1000 * 1000 # around 36 MB table
|
||||
|
||||
@@ -42,36 +37,46 @@ def test_random_writes(zenith_with_baseline: PgCompare):
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Create the test table
|
||||
with env.record_duration('init'):
|
||||
cur.execute("""
|
||||
with env.record_duration("init"):
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE Big(
|
||||
pk integer primary key,
|
||||
count integer default 0
|
||||
);
|
||||
""")
|
||||
cur.execute(f"INSERT INTO Big (pk) values (generate_series(1,{n_rows}))")
|
||||
"""
|
||||
)
|
||||
|
||||
# Insert n_rows in batches to avoid query timeouts
|
||||
rows_inserted = 0
|
||||
while rows_inserted < n_rows:
|
||||
rows_to_insert = min(1000 * 1000, n_rows - rows_inserted)
|
||||
low = rows_inserted + 1
|
||||
high = rows_inserted + rows_to_insert
|
||||
cur.execute(f"INSERT INTO Big (pk) values (generate_series({low},{high}))")
|
||||
rows_inserted += rows_to_insert
|
||||
|
||||
# Get table size (can't be predicted because padding and alignment)
|
||||
cur.execute("SELECT pg_relation_size('Big');")
|
||||
row = cur.fetchone()
|
||||
table_size = row[0]
|
||||
env.zenbenchmark.record("table_size", table_size, 'bytes', MetricReport.TEST_PARAM)
|
||||
table_size = query_scalar(cur, "SELECT pg_relation_size('Big')")
|
||||
env.zenbenchmark.record("table_size", table_size, "bytes", MetricReport.TEST_PARAM)
|
||||
|
||||
# Decide how much to write, based on knowledge of pageserver implementation.
|
||||
# Avoiding segment collisions maximizes (zenith_runtime / vanilla_runtime).
|
||||
# Avoiding segment collisions maximizes (neon_runtime / vanilla_runtime).
|
||||
segment_size = 10 * 1024 * 1024
|
||||
n_segments = table_size // segment_size
|
||||
n_writes = load_factor * n_segments // 3
|
||||
|
||||
# The closer this is to 250 MB, the more realistic the test is.
|
||||
effective_checkpoint_distance = table_size * n_writes // n_rows
|
||||
env.zenbenchmark.record("effective_checkpoint_distance",
|
||||
effective_checkpoint_distance,
|
||||
'bytes',
|
||||
MetricReport.TEST_PARAM)
|
||||
env.zenbenchmark.record(
|
||||
"effective_checkpoint_distance",
|
||||
effective_checkpoint_distance,
|
||||
"bytes",
|
||||
MetricReport.TEST_PARAM,
|
||||
)
|
||||
|
||||
# Update random keys
|
||||
with env.record_duration('run'):
|
||||
with env.record_duration("run"):
|
||||
for it in range(n_iterations):
|
||||
for i in range(n_writes):
|
||||
key = random.randint(1, n_rows)
|
||||
|
||||
31
test_runner/performance/test_read_trace.py
Normal file
31
test_runner/performance/test_read_trace.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from contextlib import closing
|
||||
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder
|
||||
|
||||
|
||||
# This test demonstrates how to collect a read trace. It's useful until
|
||||
# it gets replaced by a test that actually does stuff with the trace.
|
||||
def test_read_request_tracing(neon_env_builder: NeonEnvBuilder):
|
||||
neon_env_builder.num_safekeepers = 1
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
"trace_read_requests": "true",
|
||||
}
|
||||
)
|
||||
|
||||
timeline = env.neon_cli.create_timeline("test_trace_replay", tenant_id=tenant)
|
||||
pg = env.postgres.create_start("test_trace_replay", "main", tenant)
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("create table t (i integer);")
|
||||
cur.execute(f"insert into t values (generate_series(1,{10000}));")
|
||||
cur.execute("select count(*) from t;")
|
||||
|
||||
# Stop pg so we drop the connection and flush the traces
|
||||
pg.stop()
|
||||
|
||||
trace_path = env.repo_dir / "traces" / str(tenant) / str(timeline)
|
||||
assert trace_path.exists()
|
||||
50
test_runner/performance/test_seqscans.py
Normal file
50
test_runner/performance/test_seqscans.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Test sequential scan speed
|
||||
#
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
from fixtures.log_helper import log
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rows,iters,workers",
|
||||
[
|
||||
# The test table is large enough (3-4 MB) that it doesn't fit in the compute node
|
||||
# cache, so the seqscans go to the page server. But small enough that it fits
|
||||
# into memory in the page server.
|
||||
pytest.param(100000, 100, 0),
|
||||
# Also test with a larger table, with and without parallelism
|
||||
pytest.param(10000000, 1, 0),
|
||||
pytest.param(10000000, 1, 4),
|
||||
],
|
||||
)
|
||||
def test_seqscans(neon_with_baseline: PgCompare, rows: int, iters: int, workers: int):
|
||||
env = neon_with_baseline
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("create table t (i integer);")
|
||||
cur.execute(f"insert into t values (generate_series(1,{rows}));")
|
||||
|
||||
# Verify that the table is larger than shared_buffers
|
||||
cur.execute(
|
||||
"""
|
||||
select setting::int * pg_size_bytes(unit) as shared_buffers, pg_relation_size('t') as tbl_ize
|
||||
from pg_settings where name = 'shared_buffers'
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None
|
||||
shared_buffers = row[0]
|
||||
table_size = row[1]
|
||||
log.info(f"shared_buffers is {shared_buffers}, table size {table_size}")
|
||||
assert int(shared_buffers) < int(table_size)
|
||||
env.zenbenchmark.record("table_size", table_size, "bytes", MetricReport.TEST_PARAM)
|
||||
|
||||
cur.execute(f"set max_parallel_workers_per_gather = {workers}")
|
||||
|
||||
with env.record_duration("run"):
|
||||
for i in range(iters):
|
||||
cur.execute("select count(*) from t;")
|
||||
@@ -1,41 +0,0 @@
|
||||
# Test sequential scan speed
|
||||
#
|
||||
# The test table is large enough (3-4 MB) that it doesn't fit in the compute node
|
||||
# cache, so the seqscans go to the page server. But small enough that it fits
|
||||
# into memory in the page server.
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.benchmark_fixture import MetricReport, ZenithBenchmarker
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rows', [
|
||||
pytest.param(100000),
|
||||
pytest.param(1000000, marks=pytest.mark.slow),
|
||||
])
|
||||
def test_small_seqscans(zenith_with_baseline: PgCompare, rows: int):
|
||||
env = zenith_with_baseline
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('create table t (i integer);')
|
||||
cur.execute(f'insert into t values (generate_series(1,{rows}));')
|
||||
|
||||
# Verify that the table is larger than shared_buffers
|
||||
cur.execute('''
|
||||
select setting::int * pg_size_bytes(unit) as shared_buffers, pg_relation_size('t') as tbl_ize
|
||||
from pg_settings where name = 'shared_buffers'
|
||||
''')
|
||||
row = cur.fetchone()
|
||||
shared_buffers = row[0]
|
||||
table_size = row[1]
|
||||
log.info(f"shared_buffers is {shared_buffers}, table size {table_size}")
|
||||
assert int(shared_buffers) < int(table_size)
|
||||
env.zenbenchmark.record("table_size", table_size, 'bytes', MetricReport.TEST_PARAM)
|
||||
|
||||
with env.record_duration('run'):
|
||||
for i in range(1000):
|
||||
cur.execute('select count(*) from t;')
|
||||
51
test_runner/performance/test_startup.py
Normal file
51
test_runner/performance/test_startup.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fixtures.benchmark_fixture import NeonBenchmarker
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder
|
||||
|
||||
|
||||
# This test sometimes runs for longer than the global 5 minute timeout.
|
||||
@pytest.mark.timeout(600)
|
||||
def test_startup(neon_env_builder: NeonEnvBuilder, zenbenchmark: NeonBenchmarker):
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
# Start
|
||||
env.neon_cli.create_branch("test_startup")
|
||||
with zenbenchmark.record_duration("startup_time"):
|
||||
pg = env.postgres.create_start("test_startup")
|
||||
pg.safe_psql("select 1;")
|
||||
|
||||
# Restart
|
||||
pg.stop_and_destroy()
|
||||
with zenbenchmark.record_duration("restart_time"):
|
||||
pg.create_start("test_startup")
|
||||
pg.safe_psql("select 1;")
|
||||
|
||||
# Fill up
|
||||
num_rows = 1000000 # 30 MB
|
||||
num_tables = 100
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(num_tables):
|
||||
cur.execute(f"create table t_{i} (i integer);")
|
||||
cur.execute(f"insert into t_{i} values (generate_series(1,{num_rows}));")
|
||||
|
||||
# Read
|
||||
with zenbenchmark.record_duration("read_time"):
|
||||
pg.safe_psql("select * from t_0;")
|
||||
|
||||
# Read again
|
||||
with zenbenchmark.record_duration("second_read_time"):
|
||||
pg.safe_psql("select * from t_0;")
|
||||
|
||||
# Restart
|
||||
pg.stop_and_destroy()
|
||||
with zenbenchmark.record_duration("restart_with_data"):
|
||||
pg.create_start("test_startup")
|
||||
pg.safe_psql("select 1;")
|
||||
|
||||
# Read
|
||||
with zenbenchmark.record_duration("read_after_restart"):
|
||||
pg.safe_psql("select * from t_0;")
|
||||
285
test_runner/performance/test_wal_backpressure.py
Normal file
285
test_runner/performance/test_wal_backpressure.py
Normal file
@@ -0,0 +1,285 @@
|
||||
import statistics
|
||||
import threading
|
||||
import time
|
||||
import timeit
|
||||
from typing import Any, Callable, List
|
||||
|
||||
import pytest
|
||||
from fixtures.benchmark_fixture import MetricReport, NeonBenchmarker
|
||||
from fixtures.compare_fixtures import NeonCompare, PgCompare, VanillaCompare
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import DEFAULT_BRANCH_NAME, NeonEnvBuilder, PgBin
|
||||
from fixtures.types import Lsn
|
||||
from performance.test_perf_pgbench import get_durations_matrix, get_scales_matrix
|
||||
|
||||
|
||||
@pytest.fixture(params=["vanilla", "neon_off", "neon_on"])
|
||||
# This fixture constructs multiple `PgCompare` interfaces using a builder pattern.
|
||||
# The builder parameters are encoded in the fixture's param.
|
||||
# For example, to build a `NeonCompare` interface, the corresponding fixture's param should have
|
||||
# a format of `neon_{safekeepers_enable_fsync}`.
|
||||
# Note that, here "_" is used to separate builder parameters.
|
||||
def pg_compare(request) -> PgCompare:
|
||||
x = request.param.split("_")
|
||||
|
||||
if x[0] == "vanilla":
|
||||
# `VanillaCompare` interface
|
||||
fixture = request.getfixturevalue("vanilla_compare")
|
||||
assert isinstance(fixture, VanillaCompare)
|
||||
|
||||
return fixture
|
||||
else:
|
||||
assert (
|
||||
len(x) == 2
|
||||
), f"request param ({request.param}) should have a format of \
|
||||
`neon_{{safekeepers_enable_fsync}}`"
|
||||
|
||||
# `NeonCompare` interface
|
||||
neon_env_builder = request.getfixturevalue("neon_env_builder")
|
||||
assert isinstance(neon_env_builder, NeonEnvBuilder)
|
||||
|
||||
zenbenchmark = request.getfixturevalue("zenbenchmark")
|
||||
assert isinstance(zenbenchmark, NeonBenchmarker)
|
||||
|
||||
pg_bin = request.getfixturevalue("pg_bin")
|
||||
assert isinstance(pg_bin, PgBin)
|
||||
|
||||
neon_env_builder.safekeepers_enable_fsync = x[1] == "on"
|
||||
|
||||
env = neon_env_builder.init_start()
|
||||
env.neon_cli.create_branch("empty", ancestor_branch_name=DEFAULT_BRANCH_NAME)
|
||||
|
||||
branch_name = request.node.name
|
||||
return NeonCompare(zenbenchmark, env, pg_bin, branch_name)
|
||||
|
||||
|
||||
def start_heavy_write_workload(env: PgCompare, n_tables: int, scale: int, num_iters: int):
|
||||
"""Start an intensive write workload across multiple tables.
|
||||
|
||||
## Single table workload:
|
||||
At each step, insert new `new_rows_each_update` rows.
|
||||
The variable `new_rows_each_update` is equal to `scale * 100_000`.
|
||||
The number of steps is determined by `num_iters` variable."""
|
||||
new_rows_each_update = scale * 100_000
|
||||
|
||||
def start_single_table_workload(table_id: int):
|
||||
for _ in range(num_iters):
|
||||
with env.pg.connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"INSERT INTO t{table_id} SELECT FROM generate_series(1,{new_rows_each_update})"
|
||||
)
|
||||
|
||||
with env.record_duration("run_duration"):
|
||||
threads = [
|
||||
threading.Thread(target=start_single_table_workload, args=(i,)) for i in range(n_tables)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
|
||||
@pytest.mark.timeout(1000)
|
||||
@pytest.mark.parametrize("n_tables", [5])
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix(5))
|
||||
@pytest.mark.parametrize("num_iters", [10])
|
||||
def test_heavy_write_workload(pg_compare: PgCompare, n_tables: int, scale: int, num_iters: int):
|
||||
env = pg_compare
|
||||
|
||||
# Initializes test tables
|
||||
with env.pg.connect().cursor() as cur:
|
||||
for i in range(n_tables):
|
||||
cur.execute(
|
||||
f"CREATE TABLE t{i}(key serial primary key, t text default 'foooooooooooooooooooooooooooooooooooooooooooooooooooo')"
|
||||
)
|
||||
cur.execute(f"INSERT INTO t{i} (key) VALUES (0)")
|
||||
|
||||
workload_thread = threading.Thread(
|
||||
target=start_heavy_write_workload, args=(env, n_tables, scale, num_iters)
|
||||
)
|
||||
workload_thread.start()
|
||||
|
||||
record_thread = threading.Thread(
|
||||
target=record_lsn_write_lag, args=(env, lambda: workload_thread.is_alive())
|
||||
)
|
||||
record_thread.start()
|
||||
|
||||
record_read_latency(env, lambda: workload_thread.is_alive(), "SELECT * from t0 where key = 0")
|
||||
workload_thread.join()
|
||||
record_thread.join()
|
||||
|
||||
|
||||
def start_pgbench_simple_update_workload(env: PgCompare, duration: int):
|
||||
with env.record_duration("run_duration"):
|
||||
env.pg_bin.run_capture(
|
||||
[
|
||||
"pgbench",
|
||||
"-j10",
|
||||
"-c10",
|
||||
"-N",
|
||||
f"-T{duration}",
|
||||
env.pg.connstr(options="-csynchronous_commit=off"),
|
||||
]
|
||||
)
|
||||
env.flush()
|
||||
|
||||
|
||||
@pytest.mark.timeout(1000)
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix(100))
|
||||
@pytest.mark.parametrize("duration", get_durations_matrix())
|
||||
def test_pgbench_simple_update_workload(pg_compare: PgCompare, scale: int, duration: int):
|
||||
env = pg_compare
|
||||
|
||||
# initialize pgbench tables
|
||||
env.pg_bin.run_capture(["pgbench", f"-s{scale}", "-i", env.pg.connstr()])
|
||||
env.flush()
|
||||
|
||||
workload_thread = threading.Thread(
|
||||
target=start_pgbench_simple_update_workload, args=(env, duration)
|
||||
)
|
||||
workload_thread.start()
|
||||
|
||||
record_thread = threading.Thread(
|
||||
target=record_lsn_write_lag, args=(env, lambda: workload_thread.is_alive())
|
||||
)
|
||||
record_thread.start()
|
||||
|
||||
record_read_latency(
|
||||
env, lambda: workload_thread.is_alive(), "SELECT * from pgbench_accounts where aid = 1"
|
||||
)
|
||||
workload_thread.join()
|
||||
record_thread.join()
|
||||
|
||||
|
||||
def start_pgbench_intensive_initialization(env: PgCompare, scale: int, done_event: threading.Event):
|
||||
with env.record_duration("run_duration"):
|
||||
# Needs to increase the statement timeout (default: 120s) because the
|
||||
# initialization step can be slow with a large scale.
|
||||
env.pg_bin.run_capture(
|
||||
[
|
||||
"pgbench",
|
||||
f"-s{scale}",
|
||||
"-i",
|
||||
"-Idtg",
|
||||
env.pg.connstr(options="-cstatement_timeout=600s"),
|
||||
]
|
||||
)
|
||||
|
||||
done_event.set()
|
||||
|
||||
|
||||
@pytest.mark.timeout(1000)
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix(1000))
|
||||
def test_pgbench_intensive_init_workload(pg_compare: PgCompare, scale: int):
|
||||
env = pg_compare
|
||||
with env.pg.connect().cursor() as cur:
|
||||
cur.execute("CREATE TABLE foo as select generate_series(1,100000)")
|
||||
|
||||
workload_done_event = threading.Event()
|
||||
|
||||
workload_thread = threading.Thread(
|
||||
target=start_pgbench_intensive_initialization, args=(env, scale, workload_done_event)
|
||||
)
|
||||
workload_thread.start()
|
||||
|
||||
record_thread = threading.Thread(
|
||||
target=record_lsn_write_lag, args=(env, lambda: not workload_done_event.is_set())
|
||||
)
|
||||
record_thread.start()
|
||||
|
||||
record_read_latency(env, lambda: not workload_done_event.is_set(), "SELECT count(*) from foo")
|
||||
workload_thread.join()
|
||||
record_thread.join()
|
||||
|
||||
|
||||
def record_lsn_write_lag(env: PgCompare, run_cond: Callable[[], bool], pool_interval: float = 1.0):
|
||||
if not isinstance(env, NeonCompare):
|
||||
return
|
||||
|
||||
lsn_write_lags: List[Any] = []
|
||||
last_received_lsn = Lsn(0)
|
||||
last_pg_flush_lsn = Lsn(0)
|
||||
|
||||
with env.pg.connect().cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION neon")
|
||||
|
||||
while run_cond():
|
||||
cur.execute(
|
||||
"""
|
||||
select pg_wal_lsn_diff(pg_current_wal_flush_lsn(),received_lsn),
|
||||
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_flush_lsn(),received_lsn)),
|
||||
pg_current_wal_flush_lsn(),
|
||||
received_lsn
|
||||
from backpressure_lsns();
|
||||
"""
|
||||
)
|
||||
|
||||
res = cur.fetchone()
|
||||
assert isinstance(res, tuple)
|
||||
lsn_write_lags.append(res[0])
|
||||
|
||||
curr_received_lsn = Lsn(res[3])
|
||||
lsn_process_speed = (curr_received_lsn - last_received_lsn) / (1024**2)
|
||||
last_received_lsn = curr_received_lsn
|
||||
|
||||
curr_pg_flush_lsn = Lsn(res[2])
|
||||
lsn_produce_speed = (curr_pg_flush_lsn - last_pg_flush_lsn) / (1024**2)
|
||||
last_pg_flush_lsn = curr_pg_flush_lsn
|
||||
|
||||
log.info(
|
||||
f"received_lsn_lag={res[1]}, pg_flush_lsn={res[2]}, received_lsn={res[3]}, lsn_process_speed={lsn_process_speed:.2f}MB/s, lsn_produce_speed={lsn_produce_speed:.2f}MB/s"
|
||||
)
|
||||
|
||||
time.sleep(pool_interval)
|
||||
|
||||
env.zenbenchmark.record(
|
||||
"lsn_write_lag_max",
|
||||
float(max(lsn_write_lags) / (1024**2)),
|
||||
"MB",
|
||||
MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
env.zenbenchmark.record(
|
||||
"lsn_write_lag_avg",
|
||||
float(statistics.mean(lsn_write_lags) / (1024**2)),
|
||||
"MB",
|
||||
MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
env.zenbenchmark.record(
|
||||
"lsn_write_lag_stdev",
|
||||
float(statistics.stdev(lsn_write_lags) / (1024**2)),
|
||||
"MB",
|
||||
MetricReport.LOWER_IS_BETTER,
|
||||
)
|
||||
|
||||
|
||||
def record_read_latency(
|
||||
env: PgCompare, run_cond: Callable[[], bool], read_query: str, read_interval: float = 1.0
|
||||
):
|
||||
read_latencies = []
|
||||
|
||||
with env.pg.connect().cursor() as cur:
|
||||
while run_cond():
|
||||
try:
|
||||
t1 = timeit.default_timer()
|
||||
cur.execute(read_query)
|
||||
t2 = timeit.default_timer()
|
||||
|
||||
log.info(
|
||||
f"Executed read query {read_query}, got {cur.fetchall()}, read time {t2-t1:.2f}s"
|
||||
)
|
||||
read_latencies.append(t2 - t1)
|
||||
except Exception as err:
|
||||
log.error(f"Got error when executing the read query: {err}")
|
||||
|
||||
time.sleep(read_interval)
|
||||
|
||||
env.zenbenchmark.record(
|
||||
"read_latency_max", max(read_latencies), "s", MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
env.zenbenchmark.record(
|
||||
"read_latency_avg", statistics.mean(read_latencies), "s", MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
env.zenbenchmark.record(
|
||||
"read_latency_stdev", statistics.stdev(read_latencies), "s", MetricReport.LOWER_IS_BETTER
|
||||
)
|
||||
@@ -10,31 +10,30 @@
|
||||
# in LSN order, writing the oldest layer first. That creates a new 10 MB image
|
||||
# layer to be created for each of those small updates. This is the Write
|
||||
# Amplification problem at its finest.
|
||||
import os
|
||||
from contextlib import closing
|
||||
from fixtures.benchmark_fixture import MetricReport
|
||||
from fixtures.zenith_fixtures import ZenithEnv
|
||||
from fixtures.compare_fixtures import PgCompare, VanillaCompare, ZenithCompare
|
||||
from fixtures.log_helper import log
|
||||
|
||||
from fixtures.compare_fixtures import PgCompare
|
||||
|
||||
|
||||
def test_write_amplification(zenith_with_baseline: PgCompare):
|
||||
env = zenith_with_baseline
|
||||
def test_write_amplification(neon_with_baseline: PgCompare):
|
||||
env = neon_with_baseline
|
||||
|
||||
with closing(env.pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with env.record_pageserver_writes('pageserver_writes'):
|
||||
with env.record_duration('run'):
|
||||
with env.record_pageserver_writes("pageserver_writes"):
|
||||
with env.record_duration("run"):
|
||||
|
||||
# NOTE: Because each iteration updates every table already created,
|
||||
# the runtime and write amplification is O(n^2), where n is the
|
||||
# number of iterations.
|
||||
for i in range(25):
|
||||
cur.execute(f'''
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE tbl{i} AS
|
||||
SELECT g as i, 'long string to consume some space' || g as t
|
||||
FROM generate_series(1, 100000) g
|
||||
''')
|
||||
"""
|
||||
)
|
||||
cur.execute(f"create index on tbl{i} (i);")
|
||||
for j in range(1, i):
|
||||
cur.execute(f"delete from tbl{j} where i = {i}")
|
||||
|
||||
2
test_runner/pg_clients/csharp/npgsql/.dockerignore
Normal file
2
test_runner/pg_clients/csharp/npgsql/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
2
test_runner/pg_clients/csharp/npgsql/.gitignore
vendored
Normal file
2
test_runner/pg_clients/csharp/npgsql/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
14
test_runner/pg_clients/csharp/npgsql/Dockerfile
Normal file
14
test_runner/pg_clients/csharp/npgsql/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
|
||||
WORKDIR /source
|
||||
|
||||
COPY *.csproj .
|
||||
RUN dotnet restore
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish -c release -o /app --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:6.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
|
||||
ENTRYPOINT ["dotnet", "csharp-npgsql.dll"]
|
||||
19
test_runner/pg_clients/csharp/npgsql/Program.cs
Normal file
19
test_runner/pg_clients/csharp/npgsql/Program.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Npgsql;
|
||||
|
||||
var host = Environment.GetEnvironmentVariable("NEON_HOST");
|
||||
var database = Environment.GetEnvironmentVariable("NEON_DATABASE");
|
||||
var user = Environment.GetEnvironmentVariable("NEON_USER");
|
||||
var password = Environment.GetEnvironmentVariable("NEON_PASSWORD");
|
||||
|
||||
var connString = $"Host={host};Username={user};Password={password};Database={database}";
|
||||
|
||||
await using var conn = new NpgsqlConnection(connString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using (var cmd = new NpgsqlCommand("SELECT 1", conn))
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
Console.WriteLine(reader.GetInt32(0));
|
||||
}
|
||||
await conn.CloseAsync();
|
||||
14
test_runner/pg_clients/csharp/npgsql/csharp-npgsql.csproj
Normal file
14
test_runner/pg_clients/csharp/npgsql/csharp-npgsql.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="6.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
test_runner/pg_clients/java/jdbc/.gitignore
vendored
Normal file
1
test_runner/pg_clients/java/jdbc/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
10
test_runner/pg_clients/java/jdbc/Dockerfile
Normal file
10
test_runner/pg_clients/java/jdbc/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM openjdk:17
|
||||
WORKDIR /source
|
||||
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app
|
||||
RUN curl --output postgresql.jar https://jdbc.postgresql.org/download/postgresql-42.4.0.jar && \
|
||||
javac -d /app /source/Example.java
|
||||
|
||||
CMD ["java", "-cp", "/app/postgresql.jar:.", "Example"]
|
||||
31
test_runner/pg_clients/java/jdbc/Example.java
Normal file
31
test_runner/pg_clients/java/jdbc/Example.java
Normal file
@@ -0,0 +1,31 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.Properties;
|
||||
|
||||
public class Example
|
||||
{
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
String host = System.getenv("NEON_HOST");
|
||||
String database = System.getenv("NEON_DATABASE");
|
||||
String user = System.getenv("NEON_USER");
|
||||
String password = System.getenv("NEON_PASSWORD");
|
||||
|
||||
String url = "jdbc:postgresql://%s/%s".formatted(host, database);
|
||||
Properties props = new Properties();
|
||||
props.setProperty("user", user);
|
||||
props.setProperty("password", password);
|
||||
|
||||
Connection conn = DriverManager.getConnection(url, props);
|
||||
Statement st = conn.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT 1");
|
||||
while (rs.next())
|
||||
{
|
||||
System.out.println(rs.getString(1));
|
||||
}
|
||||
rs.close();
|
||||
st.close();
|
||||
}
|
||||
}
|
||||
8
test_runner/pg_clients/python/asyncpg/Dockerfile
Normal file
8
test_runner/pg_clients/python/asyncpg/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.10
|
||||
WORKDIR /source
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python3 -m pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
CMD ["python3", "asyncpg_example.py"]
|
||||
29
test_runner/pg_clients/python/asyncpg/asyncpg_example.py
Executable file
29
test_runner/pg_clients/python/asyncpg/asyncpg_example.py
Executable file
@@ -0,0 +1,29 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
async def run(**kwargs) -> asyncpg.Record:
|
||||
conn = await asyncpg.connect(
|
||||
**kwargs,
|
||||
statement_cache_size=0, # Prepared statements doesn't work pgbouncer
|
||||
)
|
||||
rv = await conn.fetchrow("SELECT 1")
|
||||
await conn.close()
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kwargs = {
|
||||
k.lstrip("NEON_").lower(): v
|
||||
for k in ("NEON_HOST", "NEON_DATABASE", "NEON_USER", "NEON_PASSWORD")
|
||||
if (v := os.environ.get(k, None)) is not None
|
||||
}
|
||||
|
||||
row = asyncio.run(run(**kwargs))
|
||||
|
||||
print(row[0])
|
||||
1
test_runner/pg_clients/python/asyncpg/requirements.txt
Normal file
1
test_runner/pg_clients/python/asyncpg/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
asyncpg==0.25.0
|
||||
8
test_runner/pg_clients/python/pg8000/Dockerfile
Normal file
8
test_runner/pg_clients/python/pg8000/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.10
|
||||
WORKDIR /source
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python3 -m pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
CMD ["python3", "pg8000_example.py"]
|
||||
0
test_runner/pg_clients/python/pg8000/README.md
Normal file
0
test_runner/pg_clients/python/pg8000/README.md
Normal file
22
test_runner/pg_clients/python/pg8000/pg8000_example.py
Executable file
22
test_runner/pg_clients/python/pg8000/pg8000_example.py
Executable file
@@ -0,0 +1,22 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
import os
|
||||
|
||||
import pg8000.dbapi
|
||||
|
||||
if __name__ == "__main__":
|
||||
kwargs = {
|
||||
k.lstrip("NEON_").lower(): v
|
||||
for k in ("NEON_HOST", "NEON_DATABASE", "NEON_USER", "NEON_PASSWORD")
|
||||
if (v := os.environ.get(k, None)) is not None
|
||||
}
|
||||
conn = pg8000.dbapi.connect(
|
||||
**kwargs,
|
||||
ssl_context=True,
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
row = cursor.fetchone()
|
||||
print(row[0])
|
||||
conn.close()
|
||||
1
test_runner/pg_clients/python/pg8000/requirements.txt
Normal file
1
test_runner/pg_clients/python/pg8000/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pg8000==1.29.1
|
||||
@@ -0,0 +1 @@
|
||||
.build/
|
||||
1
test_runner/pg_clients/swift/PostgresClientKitExample/.gitignore
vendored
Normal file
1
test_runner/pg_clients/swift/PostgresClientKitExample/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM swift:5.6 AS build
|
||||
RUN apt-get -q update && apt-get -q install -y libssl-dev
|
||||
WORKDIR /source
|
||||
|
||||
COPY . .
|
||||
RUN swift build --configuration release
|
||||
|
||||
FROM swift:5.6
|
||||
WORKDIR /app
|
||||
COPY --from=build /source/.build/release/release .
|
||||
CMD ["/app/PostgresClientKitExample"]
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "bluesocket",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/IBM-Swift/BlueSocket.git",
|
||||
"state" : {
|
||||
"revision" : "dd924c3bc2c1c144c42b8dda3896f1a03115ded4",
|
||||
"version" : "2.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "bluesslservice",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/IBM-Swift/BlueSSLService",
|
||||
"state" : {
|
||||
"revision" : "c249988fb748749739144e7f554710552acdc0bd",
|
||||
"version" : "2.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "postgresclientkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/codewinsdotcom/PostgresClientKit.git",
|
||||
"state" : {
|
||||
"branch" : "v1.4.3",
|
||||
"revision" : "beafedaea6dc9f04712e9a8547b77f47c406a47e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "6b2aa2748a7881eebb9f84fb10c01293e15b52ca",
|
||||
"version" : "0.5.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// swift-tools-version:5.6
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "PostgresClientKitExample",
|
||||
dependencies: [
|
||||
.package(
|
||||
url: "https://github.com/codewinsdotcom/PostgresClientKit.git",
|
||||
revision: "v1.4.3"
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "PostgresClientKitExample",
|
||||
dependencies: [ "PostgresClientKit" ])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
import PostgresClientKit
|
||||
|
||||
do {
|
||||
var configuration = PostgresClientKit.ConnectionConfiguration()
|
||||
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let host = env["NEON_HOST"] {
|
||||
configuration.host = host
|
||||
}
|
||||
if let database = env["NEON_DATABASE"] {
|
||||
configuration.database = database
|
||||
}
|
||||
if let user = env["NEON_USER"] {
|
||||
configuration.user = user
|
||||
}
|
||||
if let password = env["NEON_PASSWORD"] {
|
||||
configuration.credential = .scramSHA256(password: password)
|
||||
}
|
||||
|
||||
let connection = try PostgresClientKit.Connection(configuration: configuration)
|
||||
defer { connection.close() }
|
||||
|
||||
let text = "SELECT 1;"
|
||||
let statement = try connection.prepareStatement(text: text)
|
||||
defer { statement.close() }
|
||||
|
||||
let cursor = try statement.execute(parameterValues: [ ])
|
||||
defer { cursor.close() }
|
||||
|
||||
for row in cursor {
|
||||
let columns = try row.get().columns
|
||||
print(columns[0])
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
54
test_runner/pg_clients/test_pg_clients.py
Normal file
54
test_runner/pg_clients/test_pg_clients.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import pytest
|
||||
from fixtures.neon_fixtures import RemotePostgres
|
||||
from fixtures.utils import subprocess_capture
|
||||
|
||||
|
||||
@pytest.mark.remote_cluster
|
||||
@pytest.mark.parametrize(
|
||||
"client",
|
||||
[
|
||||
"csharp/npgsql",
|
||||
"java/jdbc",
|
||||
"python/asyncpg",
|
||||
pytest.param(
|
||||
"python/pg8000", # See https://github.com/neondatabase/neon/pull/2008#discussion_r912264281
|
||||
marks=pytest.mark.xfail(reason="Handles SSL in incompatible with Neon way"),
|
||||
),
|
||||
pytest.param(
|
||||
"swift/PostgresClientKit", # See https://github.com/neondatabase/neon/pull/2008#discussion_r911896592
|
||||
marks=pytest.mark.xfail(reason="Neither SNI nor parameters is supported"),
|
||||
),
|
||||
"typescript/postgresql-client",
|
||||
],
|
||||
)
|
||||
def test_pg_clients(test_output_dir: Path, remote_pg: RemotePostgres, client: str):
|
||||
conn_options = remote_pg.conn_options()
|
||||
|
||||
env_file = None
|
||||
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
env_file = f.name
|
||||
f.write(
|
||||
f"""
|
||||
NEON_HOST={conn_options["host"]}
|
||||
NEON_DATABASE={conn_options["dbname"]}
|
||||
NEON_USER={conn_options["user"]}
|
||||
NEON_PASSWORD={conn_options["password"]}
|
||||
"""
|
||||
)
|
||||
|
||||
image_tag = client.lower()
|
||||
docker_bin = shutil.which("docker")
|
||||
if docker_bin is None:
|
||||
raise RuntimeError("docker is required for running this test")
|
||||
|
||||
build_cmd = [docker_bin, "build", "--tag", image_tag, f"{Path(__file__).parent / client}"]
|
||||
subprocess_capture(test_output_dir, build_cmd, check=True)
|
||||
|
||||
run_cmd = [docker_bin, "run", "--rm", "--env-file", env_file, image_tag]
|
||||
basepath = subprocess_capture(test_output_dir, run_cmd, check=True)
|
||||
|
||||
assert Path(f"{basepath}.stdout").read_text().strip() == "1"
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
1
test_runner/pg_clients/typescript/postgresql-client/.gitignore
vendored
Normal file
1
test_runner/pg_clients/typescript/postgresql-client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:16
|
||||
WORKDIR /source
|
||||
|
||||
COPY . .
|
||||
RUN npm clean-install
|
||||
|
||||
CMD ["/source/index.js"]
|
||||
25
test_runner/pg_clients/typescript/postgresql-client/index.js
Executable file
25
test_runner/pg_clients/typescript/postgresql-client/index.js
Executable file
@@ -0,0 +1,25 @@
|
||||
#! /usr/bin/env node
|
||||
|
||||
import {Connection} from 'postgresql-client';
|
||||
|
||||
const params = {
|
||||
"host": process.env.NEON_HOST,
|
||||
"database": process.env.NEON_DATABASE,
|
||||
"user": process.env.NEON_USER,
|
||||
"password": process.env.NEON_PASSWORD,
|
||||
"ssl": true,
|
||||
}
|
||||
for (const key in params) {
|
||||
if (params[key] === undefined) {
|
||||
delete params[key];
|
||||
}
|
||||
}
|
||||
|
||||
const connection = new Connection(params);
|
||||
await connection.connect();
|
||||
const result = await connection.query(
|
||||
'select 1'
|
||||
);
|
||||
const rows = result.rows;
|
||||
await connection.close();
|
||||
console.log(rows[0][0]);
|
||||
262
test_runner/pg_clients/typescript/postgresql-client/package-lock.json
generated
Normal file
262
test_runner/pg_clients/typescript/postgresql-client/package-lock.json
generated
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"name": "typescript",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"postgresql-client": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/doublylinked": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/doublylinked/-/doublylinked-2.5.1.tgz",
|
||||
"integrity": "sha512-Lpqb+qyHpR5Bew8xfKsxVYdjXEYAQ7HLp1IX47kHKmVCZeXErInytonjkL+kE+L4yaKSYEmDNR9MJYr5zwuAKA==",
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightning-pool": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lightning-pool/-/lightning-pool-3.1.3.tgz",
|
||||
"integrity": "sha512-OgWuoh0BBrikWx/mc/XwIKwC9HHTe/GU3XODLMBPibv7jv8u0o2gQFS7KVEg5U8Oufg6N7mkm8Y1RoiLER0zeQ==",
|
||||
"dependencies": {
|
||||
"doublylinked": "^2.4.3",
|
||||
"putil-promisify": "^1.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/obuf": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
|
||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
|
||||
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
|
||||
"dependencies": {
|
||||
"obuf": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/postgresql-client": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/postgresql-client/-/postgresql-client-2.1.3.tgz",
|
||||
"integrity": "sha512-36Ga6JzhydsRzcCRcA/Y2hrX9C9sI0wS6sgRNBlOGkOwACXQVybmhDM7mAUbi9cT00N39Ee7btR0eMCyD//5Xg==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"doublylinked": "^2.5.1",
|
||||
"lightning-pool": "^3.1.3",
|
||||
"postgres-bytea": "^3.0.0",
|
||||
"power-tasks": "^0.8.0",
|
||||
"putil-merge": "^3.8.0",
|
||||
"putil-promisify": "^1.8.5",
|
||||
"putil-varhelpers": "^1.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/power-tasks": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/power-tasks/-/power-tasks-0.8.0.tgz",
|
||||
"integrity": "sha512-HhMcx+y5UkzlEmKslruz8uAU2Yq8CODJsFEMFsYMrGp5EzKpkNHGu0RNvBqyewKJDZHPNKtBSULsEAxMqQIBVQ==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"doublylinked": "^2.5.1",
|
||||
"strict-typed-events": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/putil-merge": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/putil-merge/-/putil-merge-3.8.0.tgz",
|
||||
"integrity": "sha512-5tXPafJawWFoYZWLhkYXZ7IC/qkI45HgJsgv36lJBeq3qjFZfUITZE01CmWUBIlIn9f1yDiikqgYERARhVmgrg==",
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/putil-promisify": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/putil-promisify/-/putil-promisify-1.8.5.tgz",
|
||||
"integrity": "sha512-DItclasWWZokvpq3Aiaq0iV7WC8isP/0o/8mhC0yV6CQ781N/7NQHA1VyOm6hfpeFEwIQoo1C4Yjc5eH0q6Jbw==",
|
||||
"engines": {
|
||||
"node": ">= 6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/putil-varhelpers": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/putil-varhelpers/-/putil-varhelpers-1.6.4.tgz",
|
||||
"integrity": "sha512-nM2nO1HS2yJUyPgz0grd2XZAM0Spr6/tt6F4xXeNDjByV00BV2mq6lZ+sDff8WIfQBI9Hn1Czh93H1xBvKESxw==",
|
||||
"engines": {
|
||||
"node": ">= 6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-typed-events": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-typed-events/-/strict-typed-events-2.2.0.tgz",
|
||||
"integrity": "sha512-yvHRtEfRRV7TJWi9cLhMt4Mb12JtAwXXONltUlLCA3fRB0LRy94B4E4e2gIlXzT5nZHTZVpOjJNOshri3LZ5bw==",
|
||||
"dependencies": {
|
||||
"putil-promisify": "^1.8.5",
|
||||
"ts-gems": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-gems": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-gems/-/ts-gems-2.1.0.tgz",
|
||||
"integrity": "sha512-5IqiG4nq1tsOhYPc4CwxA6bsE+TfU6uAABzf6bH4sdElgXpt/mlStvIYedvvtU7BM1+RRJxCaTLaaVFcCqNaiA==",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
|
||||
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"requires": {
|
||||
"ms": "2.1.2"
|
||||
}
|
||||
},
|
||||
"doublylinked": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/doublylinked/-/doublylinked-2.5.1.tgz",
|
||||
"integrity": "sha512-Lpqb+qyHpR5Bew8xfKsxVYdjXEYAQ7HLp1IX47kHKmVCZeXErInytonjkL+kE+L4yaKSYEmDNR9MJYr5zwuAKA=="
|
||||
},
|
||||
"lightning-pool": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lightning-pool/-/lightning-pool-3.1.3.tgz",
|
||||
"integrity": "sha512-OgWuoh0BBrikWx/mc/XwIKwC9HHTe/GU3XODLMBPibv7jv8u0o2gQFS7KVEg5U8Oufg6N7mkm8Y1RoiLER0zeQ==",
|
||||
"requires": {
|
||||
"doublylinked": "^2.4.3",
|
||||
"putil-promisify": "^1.8.2"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"obuf": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
|
||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
|
||||
},
|
||||
"postgres-bytea": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
|
||||
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
|
||||
"requires": {
|
||||
"obuf": "~1.1.2"
|
||||
}
|
||||
},
|
||||
"postgresql-client": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/postgresql-client/-/postgresql-client-2.1.3.tgz",
|
||||
"integrity": "sha512-36Ga6JzhydsRzcCRcA/Y2hrX9C9sI0wS6sgRNBlOGkOwACXQVybmhDM7mAUbi9cT00N39Ee7btR0eMCyD//5Xg==",
|
||||
"requires": {
|
||||
"debug": "^4.3.4",
|
||||
"doublylinked": "^2.5.1",
|
||||
"lightning-pool": "^3.1.3",
|
||||
"postgres-bytea": "^3.0.0",
|
||||
"power-tasks": "^0.8.0",
|
||||
"putil-merge": "^3.8.0",
|
||||
"putil-promisify": "^1.8.5",
|
||||
"putil-varhelpers": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"power-tasks": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/power-tasks/-/power-tasks-0.8.0.tgz",
|
||||
"integrity": "sha512-HhMcx+y5UkzlEmKslruz8uAU2Yq8CODJsFEMFsYMrGp5EzKpkNHGu0RNvBqyewKJDZHPNKtBSULsEAxMqQIBVQ==",
|
||||
"requires": {
|
||||
"debug": "^4.3.4",
|
||||
"doublylinked": "^2.5.1",
|
||||
"strict-typed-events": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"putil-merge": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/putil-merge/-/putil-merge-3.8.0.tgz",
|
||||
"integrity": "sha512-5tXPafJawWFoYZWLhkYXZ7IC/qkI45HgJsgv36lJBeq3qjFZfUITZE01CmWUBIlIn9f1yDiikqgYERARhVmgrg=="
|
||||
},
|
||||
"putil-promisify": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/putil-promisify/-/putil-promisify-1.8.5.tgz",
|
||||
"integrity": "sha512-DItclasWWZokvpq3Aiaq0iV7WC8isP/0o/8mhC0yV6CQ781N/7NQHA1VyOm6hfpeFEwIQoo1C4Yjc5eH0q6Jbw=="
|
||||
},
|
||||
"putil-varhelpers": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/putil-varhelpers/-/putil-varhelpers-1.6.4.tgz",
|
||||
"integrity": "sha512-nM2nO1HS2yJUyPgz0grd2XZAM0Spr6/tt6F4xXeNDjByV00BV2mq6lZ+sDff8WIfQBI9Hn1Czh93H1xBvKESxw=="
|
||||
},
|
||||
"strict-typed-events": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-typed-events/-/strict-typed-events-2.2.0.tgz",
|
||||
"integrity": "sha512-yvHRtEfRRV7TJWi9cLhMt4Mb12JtAwXXONltUlLCA3fRB0LRy94B4E4e2gIlXzT5nZHTZVpOjJNOshri3LZ5bw==",
|
||||
"requires": {
|
||||
"putil-promisify": "^1.8.5",
|
||||
"ts-gems": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"ts-gems": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-gems/-/ts-gems-2.1.0.tgz",
|
||||
"integrity": "sha512-5IqiG4nq1tsOhYPc4CwxA6bsE+TfU6uAABzf6bH4sdElgXpt/mlStvIYedvvtU7BM1+RRJxCaTLaaVFcCqNaiA==",
|
||||
"requires": {}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
|
||||
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"postgresql-client": "^2.1.3"
|
||||
}
|
||||
}
|
||||
104
test_runner/regress/test_ancestor_branch.py
Normal file
104
test_runner/regress/test_ancestor_branch.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder
|
||||
from fixtures.types import TimelineId
|
||||
from fixtures.utils import query_scalar
|
||||
|
||||
|
||||
#
|
||||
# Create ancestor branches off the main branch.
|
||||
#
|
||||
def test_ancestor_branch(neon_env_builder: NeonEnvBuilder):
|
||||
env = neon_env_builder.init_start()
|
||||
pageserver_http = env.pageserver.http_client()
|
||||
|
||||
# Override defaults, 1M gc_horizon and 4M checkpoint_distance.
|
||||
# Extend compaction_period and gc_period to disable background compaction and gc.
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
"gc_period": "10 m",
|
||||
"gc_horizon": "1048576",
|
||||
"checkpoint_distance": "4194304",
|
||||
"compaction_period": "10 m",
|
||||
"compaction_threshold": "2",
|
||||
"compaction_target_size": "4194304",
|
||||
}
|
||||
)
|
||||
|
||||
pageserver_http.configure_failpoints(("flush-frozen-before-sync", "sleep(10000)"))
|
||||
|
||||
pg_branch0 = env.postgres.create_start("main", tenant_id=tenant)
|
||||
branch0_cur = pg_branch0.connect().cursor()
|
||||
branch0_timeline = TimelineId(query_scalar(branch0_cur, "SHOW neon.timeline_id"))
|
||||
log.info(f"b0 timeline {branch0_timeline}")
|
||||
|
||||
# Create table, and insert 100k rows.
|
||||
branch0_lsn = query_scalar(branch0_cur, "SELECT pg_current_wal_insert_lsn()")
|
||||
log.info(f"b0 at lsn {branch0_lsn}")
|
||||
|
||||
branch0_cur.execute("CREATE TABLE foo (t text) WITH (autovacuum_enabled = off)")
|
||||
branch0_cur.execute(
|
||||
"""
|
||||
INSERT INTO foo
|
||||
SELECT '00112233445566778899AABBCCDDEEFF' || ':branch0:' || g
|
||||
FROM generate_series(1, 100000) g
|
||||
"""
|
||||
)
|
||||
lsn_100 = query_scalar(branch0_cur, "SELECT pg_current_wal_insert_lsn()")
|
||||
log.info(f"LSN after 100k rows: {lsn_100}")
|
||||
|
||||
# Create branch1.
|
||||
env.neon_cli.create_branch("branch1", "main", tenant_id=tenant, ancestor_start_lsn=lsn_100)
|
||||
pg_branch1 = env.postgres.create_start("branch1", tenant_id=tenant)
|
||||
log.info("postgres is running on 'branch1' branch")
|
||||
|
||||
branch1_cur = pg_branch1.connect().cursor()
|
||||
branch1_timeline = TimelineId(query_scalar(branch1_cur, "SHOW neon.timeline_id"))
|
||||
log.info(f"b1 timeline {branch1_timeline}")
|
||||
|
||||
branch1_lsn = query_scalar(branch1_cur, "SELECT pg_current_wal_insert_lsn()")
|
||||
log.info(f"b1 at lsn {branch1_lsn}")
|
||||
|
||||
# Insert 100k rows.
|
||||
branch1_cur.execute(
|
||||
"""
|
||||
INSERT INTO foo
|
||||
SELECT '00112233445566778899AABBCCDDEEFF' || ':branch1:' || g
|
||||
FROM generate_series(1, 100000) g
|
||||
"""
|
||||
)
|
||||
lsn_200 = query_scalar(branch1_cur, "SELECT pg_current_wal_insert_lsn()")
|
||||
log.info(f"LSN after 200k rows: {lsn_200}")
|
||||
|
||||
# Create branch2.
|
||||
env.neon_cli.create_branch("branch2", "branch1", tenant_id=tenant, ancestor_start_lsn=lsn_200)
|
||||
pg_branch2 = env.postgres.create_start("branch2", tenant_id=tenant)
|
||||
log.info("postgres is running on 'branch2' branch")
|
||||
branch2_cur = pg_branch2.connect().cursor()
|
||||
|
||||
branch2_timeline = TimelineId(query_scalar(branch2_cur, "SHOW neon.timeline_id"))
|
||||
log.info(f"b2 timeline {branch2_timeline}")
|
||||
|
||||
branch2_lsn = query_scalar(branch2_cur, "SELECT pg_current_wal_insert_lsn()")
|
||||
log.info(f"b2 at lsn {branch2_lsn}")
|
||||
|
||||
# Insert 100k rows.
|
||||
branch2_cur.execute(
|
||||
"""
|
||||
INSERT INTO foo
|
||||
SELECT '00112233445566778899AABBCCDDEEFF' || ':branch2:' || g
|
||||
FROM generate_series(1, 100000) g
|
||||
"""
|
||||
)
|
||||
lsn_300 = query_scalar(branch2_cur, "SELECT pg_current_wal_insert_lsn()")
|
||||
log.info(f"LSN after 300k rows: {lsn_300}")
|
||||
|
||||
# Run compaction on branch1.
|
||||
compact = f"compact {tenant} {branch1_timeline}"
|
||||
log.info(compact)
|
||||
pageserver_http.timeline_compact(tenant, branch1_timeline)
|
||||
|
||||
assert query_scalar(branch0_cur, "SELECT count(*) FROM foo") == 100000
|
||||
|
||||
assert query_scalar(branch1_cur, "SELECT count(*) FROM foo") == 200000
|
||||
|
||||
assert query_scalar(branch2_cur, "SELECT count(*) FROM foo") == 300000
|
||||
75
test_runner/regress/test_auth.py
Normal file
75
test_runner/regress/test_auth.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder, PageserverApiException
|
||||
from fixtures.types import TenantId
|
||||
|
||||
|
||||
def test_pageserver_auth(neon_env_builder: NeonEnvBuilder):
|
||||
neon_env_builder.auth_enabled = True
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
ps = env.pageserver
|
||||
|
||||
tenant_token = env.auth_keys.generate_tenant_token(env.initial_tenant)
|
||||
tenant_http_client = env.pageserver.http_client(tenant_token)
|
||||
invalid_tenant_token = env.auth_keys.generate_tenant_token(TenantId.generate())
|
||||
invalid_tenant_http_client = env.pageserver.http_client(invalid_tenant_token)
|
||||
|
||||
management_token = env.auth_keys.generate_management_token()
|
||||
management_http_client = env.pageserver.http_client(management_token)
|
||||
|
||||
# this does not invoke auth check and only decodes jwt and checks it for validity
|
||||
# check both tokens
|
||||
ps.safe_psql("set FOO", password=tenant_token)
|
||||
ps.safe_psql("set FOO", password=management_token)
|
||||
|
||||
new_timeline_id = env.neon_cli.create_branch(
|
||||
"test_pageserver_auth", tenant_id=env.initial_tenant
|
||||
)
|
||||
|
||||
# tenant can create branches
|
||||
tenant_http_client.timeline_create(
|
||||
tenant_id=env.initial_tenant, ancestor_timeline_id=new_timeline_id
|
||||
)
|
||||
# console can create branches for tenant
|
||||
management_http_client.timeline_create(
|
||||
tenant_id=env.initial_tenant, ancestor_timeline_id=new_timeline_id
|
||||
)
|
||||
|
||||
# fail to create branch using token with different tenant_id
|
||||
with pytest.raises(
|
||||
PageserverApiException, match="Forbidden: Tenant id mismatch. Permission denied"
|
||||
):
|
||||
invalid_tenant_http_client.timeline_create(
|
||||
tenant_id=env.initial_tenant, ancestor_timeline_id=new_timeline_id
|
||||
)
|
||||
|
||||
# create tenant using management token
|
||||
management_http_client.tenant_create()
|
||||
|
||||
# fail to create tenant using tenant token
|
||||
with pytest.raises(
|
||||
PageserverApiException,
|
||||
match="Forbidden: Attempt to access management api with tenant scope. Permission denied",
|
||||
):
|
||||
tenant_http_client.tenant_create()
|
||||
|
||||
|
||||
def test_compute_auth_to_pageserver(neon_env_builder: NeonEnvBuilder):
|
||||
neon_env_builder.auth_enabled = True
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
branch = "test_compute_auth_to_pageserver"
|
||||
env.neon_cli.create_branch(branch)
|
||||
pg = env.postgres.create_start(branch)
|
||||
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# we rely upon autocommit after each statement
|
||||
# as waiting for acceptors happens there
|
||||
cur.execute("CREATE TABLE t(key int primary key, value text)")
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100000), 'payload'")
|
||||
cur.execute("SELECT sum(key) FROM t")
|
||||
assert cur.fetchone() == (5000050000,)
|
||||
@@ -1,14 +1,13 @@
|
||||
from contextlib import closing, contextmanager
|
||||
import psycopg2.extras
|
||||
from fixtures.zenith_fixtures import ZenithEnvBuilder
|
||||
from fixtures.log_helper import log
|
||||
import os
|
||||
import time
|
||||
import asyncpg
|
||||
from fixtures.zenith_fixtures import Postgres
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing, contextmanager
|
||||
|
||||
pytest_plugins = ("fixtures.zenith_fixtures")
|
||||
import psycopg2.extras
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder, Postgres
|
||||
|
||||
pytest_plugins = "fixtures.neon_fixtures"
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -25,7 +24,7 @@ def check_backpressure(pg: Postgres, stop_event: threading.Event, polling_interv
|
||||
log.info("checks started")
|
||||
|
||||
with pg_cur(pg) as cur:
|
||||
cur.execute("CREATE EXTENSION zenith") # TODO move it to zenith_fixtures?
|
||||
cur.execute("CREATE EXTENSION neon") # TODO move it to neon_fixtures?
|
||||
|
||||
cur.execute("select pg_size_bytes(current_setting('max_replication_write_lag'))")
|
||||
res = cur.fetchone()
|
||||
@@ -45,7 +44,8 @@ def check_backpressure(pg: Postgres, stop_event: threading.Event, polling_interv
|
||||
with pg_cur(pg) as cur:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
cur.execute('''
|
||||
cur.execute(
|
||||
"""
|
||||
select pg_wal_lsn_diff(pg_current_wal_flush_lsn(),received_lsn) as received_lsn_lag,
|
||||
pg_wal_lsn_diff(pg_current_wal_flush_lsn(),disk_consistent_lsn) as disk_consistent_lsn_lag,
|
||||
pg_wal_lsn_diff(pg_current_wal_flush_lsn(),remote_consistent_lsn) as remote_consistent_lsn_lag,
|
||||
@@ -53,16 +53,19 @@ def check_backpressure(pg: Postgres, stop_event: threading.Event, polling_interv
|
||||
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_flush_lsn(),disk_consistent_lsn)),
|
||||
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_flush_lsn(),remote_consistent_lsn))
|
||||
from backpressure_lsns();
|
||||
''')
|
||||
"""
|
||||
)
|
||||
|
||||
res = cur.fetchone()
|
||||
received_lsn_lag = res[0]
|
||||
disk_consistent_lsn_lag = res[1]
|
||||
remote_consistent_lsn_lag = res[2]
|
||||
|
||||
log.info(f"received_lsn_lag = {received_lsn_lag} ({res[3]}), "
|
||||
f"disk_consistent_lsn_lag = {disk_consistent_lsn_lag} ({res[4]}), "
|
||||
f"remote_consistent_lsn_lag = {remote_consistent_lsn_lag} ({res[5]})")
|
||||
log.info(
|
||||
f"received_lsn_lag = {received_lsn_lag} ({res[3]}), "
|
||||
f"disk_consistent_lsn_lag = {disk_consistent_lsn_lag} ({res[4]}), "
|
||||
f"remote_consistent_lsn_lag = {remote_consistent_lsn_lag} ({res[5]})"
|
||||
)
|
||||
|
||||
# Since feedback from pageserver is not immediate, we should allow some lag overflow
|
||||
lag_overflow = 5 * 1024 * 1024 # 5MB
|
||||
@@ -72,7 +75,9 @@ def check_backpressure(pg: Postgres, stop_event: threading.Event, polling_interv
|
||||
if max_replication_flush_lag_bytes > 0:
|
||||
assert disk_consistent_lsn_lag < max_replication_flush_lag_bytes + lag_overflow
|
||||
if max_replication_apply_lag_bytes > 0:
|
||||
assert remote_consistent_lsn_lag < max_replication_apply_lag_bytes + lag_overflow
|
||||
assert (
|
||||
remote_consistent_lsn_lag < max_replication_apply_lag_bytes + lag_overflow
|
||||
)
|
||||
|
||||
time.sleep(polling_interval)
|
||||
|
||||
@@ -80,7 +85,7 @@ def check_backpressure(pg: Postgres, stop_event: threading.Event, polling_interv
|
||||
log.info(f"backpressure check query failed: {e}")
|
||||
stop_event.set()
|
||||
|
||||
log.info('check thread stopped')
|
||||
log.info("check thread stopped")
|
||||
|
||||
|
||||
# This test illustrates how to tune backpressure to control the lag
|
||||
@@ -91,14 +96,15 @@ def check_backpressure(pg: Postgres, stop_event: threading.Event, polling_interv
|
||||
# If backpressure is enabled and tuned properly, insertion will be throttled, but the query will not timeout.
|
||||
|
||||
|
||||
def test_backpressure_received_lsn_lag(zenith_env_builder: ZenithEnvBuilder):
|
||||
zenith_env_builder.num_safekeepers = 1
|
||||
env = zenith_env_builder.init()
|
||||
@pytest.mark.skip("See https://github.com/neondatabase/neon/issues/1587")
|
||||
def test_backpressure_received_lsn_lag(neon_env_builder: NeonEnvBuilder):
|
||||
env = neon_env_builder.init_start()
|
||||
# Create a branch for us
|
||||
env.zenith_cli.create_branch("test_backpressure", "main")
|
||||
env.neon_cli.create_branch("test_backpressure")
|
||||
|
||||
pg = env.postgres.create_start('test_backpressure',
|
||||
config_lines=['max_replication_write_lag=30MB'])
|
||||
pg = env.postgres.create_start(
|
||||
"test_backpressure", config_lines=["max_replication_write_lag=30MB"]
|
||||
)
|
||||
log.info("postgres is running on 'test_backpressure' branch")
|
||||
|
||||
# setup check thread
|
||||
@@ -132,23 +138,29 @@ def test_backpressure_received_lsn_lag(zenith_env_builder: ZenithEnvBuilder):
|
||||
rows_inserted += 100000
|
||||
except Exception as e:
|
||||
if check_thread.is_alive():
|
||||
log.info('stopping check thread')
|
||||
log.info("stopping check thread")
|
||||
check_stop_event.set()
|
||||
check_thread.join()
|
||||
assert False, f"Exception {e} while inserting rows, but WAL lag is within configured threshold. That means backpressure is not tuned properly"
|
||||
assert (
|
||||
False
|
||||
), f"Exception {e} while inserting rows, but WAL lag is within configured threshold. That means backpressure is not tuned properly"
|
||||
else:
|
||||
assert False, f"Exception {e} while inserting rows and WAL lag overflowed configured threshold. That means backpressure doesn't work."
|
||||
assert (
|
||||
False
|
||||
), f"Exception {e} while inserting rows and WAL lag overflowed configured threshold. That means backpressure doesn't work."
|
||||
|
||||
log.info(f"inserted {rows_inserted} rows")
|
||||
|
||||
if check_thread.is_alive():
|
||||
log.info('stopping check thread')
|
||||
log.info("stopping check thread")
|
||||
check_stop_event.set()
|
||||
check_thread.join()
|
||||
log.info('check thread stopped')
|
||||
log.info("check thread stopped")
|
||||
else:
|
||||
assert False, "WAL lag overflowed configured threshold. That means backpressure doesn't work."
|
||||
assert (
|
||||
False
|
||||
), "WAL lag overflowed configured threshold. That means backpressure doesn't work."
|
||||
|
||||
|
||||
#TODO test_backpressure_disk_consistent_lsn_lag. Play with pageserver's checkpoint settings
|
||||
#TODO test_backpressure_remote_consistent_lsn_lag
|
||||
# TODO test_backpressure_disk_consistent_lsn_lag. Play with pageserver's checkpoint settings
|
||||
# TODO test_backpressure_remote_consistent_lsn_lag
|
||||
18
test_runner/regress/test_basebackup_error.py
Normal file
18
test_runner/regress/test_basebackup_error.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
from fixtures.neon_fixtures import NeonEnv
|
||||
|
||||
|
||||
#
|
||||
# Test error handling, if the 'basebackup' command fails in the middle
|
||||
# of building the tar archive.
|
||||
#
|
||||
def test_basebackup_error(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
env.neon_cli.create_branch("test_basebackup_error", "empty")
|
||||
pageserver_http = env.pageserver.http_client()
|
||||
|
||||
# Introduce failpoint
|
||||
pageserver_http.configure_failpoints(("basebackup-before-control-file", "return"))
|
||||
|
||||
with pytest.raises(Exception, match="basebackup-before-control-file"):
|
||||
env.postgres.create_start("test_basebackup_error")
|
||||
170
test_runner/regress/test_branch_and_gc.py
Normal file
170
test_runner/regress/test_branch_and_gc.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnv
|
||||
from fixtures.types import Lsn
|
||||
from fixtures.utils import query_scalar
|
||||
|
||||
|
||||
# Test the GC implementation when running with branching.
|
||||
# This test reproduces the issue https://github.com/neondatabase/neon/issues/707.
|
||||
#
|
||||
# Consider two LSNs `lsn1` and `lsn2` with some delta files as follows:
|
||||
# ...
|
||||
# p -> has an image layer xx_p with p < lsn1
|
||||
# ...
|
||||
# lsn1
|
||||
# ...
|
||||
# q -> has an image layer yy_q with lsn1 < q < lsn2
|
||||
# ...
|
||||
# lsn2
|
||||
#
|
||||
# Consider running a GC iteration such that the GC horizon is between p and lsn1
|
||||
# ...
|
||||
# p -> has an image layer xx_p with p < lsn1
|
||||
# D_start -> is a delta layer D's start (e.g D = '...-...-D_start-D_end')
|
||||
# ...
|
||||
# GC_h -> is a gc horizon such that p < GC_h < lsn1
|
||||
# ...
|
||||
# lsn1
|
||||
# ...
|
||||
# D_end -> is a delta layer D's end
|
||||
# ...
|
||||
# q -> has an image layer yy_q with lsn1 < q < lsn2
|
||||
# ...
|
||||
# lsn2
|
||||
#
|
||||
# As described in the issue #707, the image layer xx_p will be deleted as
|
||||
# its range is below the GC horizon and there exists a newer image layer yy_q (q > p).
|
||||
# However, removing xx_p will corrupt any delta layers that depend on xx_p that
|
||||
# are not deleted by GC. For example, the delta layer D is corrupted in the
|
||||
# above example because D depends on the image layer xx_p for value reconstruction.
|
||||
#
|
||||
# Because the delta layer D covering lsn1 is corrupted, creating a branch
|
||||
# starting from lsn1 should return an error as follows:
|
||||
# could not find data for key ... at LSN ..., for request at LSN ...
|
||||
def test_branch_and_gc(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
pageserver_http_client = env.pageserver.http_client()
|
||||
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
# disable background GC
|
||||
"gc_period": "10 m",
|
||||
"gc_horizon": f"{10 * 1024 ** 3}",
|
||||
# small checkpoint distance to create more delta layer files
|
||||
"checkpoint_distance": f"{1024 ** 2}",
|
||||
# set the target size to be large to allow the image layer to cover the whole key space
|
||||
"compaction_target_size": f"{1024 ** 3}",
|
||||
# tweak the default settings to allow quickly create image layers and L1 layers
|
||||
"compaction_period": "1 s",
|
||||
"compaction_threshold": "2",
|
||||
"image_creation_threshold": "1",
|
||||
# set PITR interval to be small, so we can do GC
|
||||
"pitr_interval": "1 s",
|
||||
}
|
||||
)
|
||||
|
||||
timeline_main = env.neon_cli.create_timeline("test_main", tenant_id=tenant)
|
||||
pg_main = env.postgres.create_start("test_main", tenant_id=tenant)
|
||||
|
||||
main_cur = pg_main.connect().cursor()
|
||||
|
||||
main_cur.execute(
|
||||
"CREATE TABLE foo(key serial primary key, t text default 'foooooooooooooooooooooooooooooooooooooooooooooooooooo')"
|
||||
)
|
||||
main_cur.execute("INSERT INTO foo SELECT FROM generate_series(1, 100000)")
|
||||
lsn1 = Lsn(query_scalar(main_cur, "SELECT pg_current_wal_insert_lsn()"))
|
||||
log.info(f"LSN1: {lsn1}")
|
||||
|
||||
main_cur.execute("INSERT INTO foo SELECT FROM generate_series(1, 100000)")
|
||||
lsn2 = Lsn(query_scalar(main_cur, "SELECT pg_current_wal_insert_lsn()"))
|
||||
log.info(f"LSN2: {lsn2}")
|
||||
|
||||
# Set the GC horizon so that lsn1 is inside the horizon, which means
|
||||
# we can create a new branch starting from lsn1.
|
||||
pageserver_http_client.timeline_gc(tenant, timeline_main, lsn2 - lsn1 + 1024)
|
||||
|
||||
env.neon_cli.create_branch(
|
||||
"test_branch", "test_main", tenant_id=tenant, ancestor_start_lsn=lsn1
|
||||
)
|
||||
pg_branch = env.postgres.create_start("test_branch", tenant_id=tenant)
|
||||
|
||||
branch_cur = pg_branch.connect().cursor()
|
||||
branch_cur.execute("INSERT INTO foo SELECT FROM generate_series(1, 100000)")
|
||||
|
||||
assert query_scalar(branch_cur, "SELECT count(*) FROM foo") == 200000
|
||||
|
||||
|
||||
# This test simulates a race condition happening when branch creation and GC are performed concurrently.
|
||||
#
|
||||
# Suppose we want to create a new timeline 't' from a source timeline 's' starting
|
||||
# from a lsn 'lsn'. Upon creating 't', if we don't hold the GC lock and compare 'lsn' with
|
||||
# the latest GC information carefully, it's possible for GC to accidentally remove data
|
||||
# needed by the new timeline.
|
||||
#
|
||||
# In this test, GC is requested before the branch creation but is delayed to happen after branch creation.
|
||||
# As a result, when doing GC for the source timeline, we don't have any information about
|
||||
# the upcoming new branches, so it's possible to remove data that may be needed by the new branches.
|
||||
# It's the branch creation task's job to make sure the starting 'lsn' is not out of scope
|
||||
# and prevent creating branches with invalid starting LSNs.
|
||||
#
|
||||
# For more details, see discussion in https://github.com/neondatabase/neon/pull/2101#issuecomment-1185273447.
|
||||
def test_branch_creation_before_gc(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
pageserver_http_client = env.pageserver.http_client()
|
||||
|
||||
# Disable background GC but set the `pitr_interval` to be small, so GC can delete something
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
# disable background GC
|
||||
"gc_period": "10 m",
|
||||
"gc_horizon": f"{10 * 1024 ** 3}",
|
||||
# small checkpoint distance to create more delta layer files
|
||||
"checkpoint_distance": f"{1024 ** 2}",
|
||||
# set the target size to be large to allow the image layer to cover the whole key space
|
||||
"compaction_target_size": f"{1024 ** 3}",
|
||||
# tweak the default settings to allow quickly create image layers and L1 layers
|
||||
"compaction_period": "1 s",
|
||||
"compaction_threshold": "2",
|
||||
"image_creation_threshold": "1",
|
||||
# set PITR interval to be small, so we can do GC
|
||||
"pitr_interval": "0 s",
|
||||
}
|
||||
)
|
||||
|
||||
b0 = env.neon_cli.create_branch("b0", tenant_id=tenant)
|
||||
pg0 = env.postgres.create_start("b0", tenant_id=tenant)
|
||||
res = pg0.safe_psql_many(
|
||||
queries=[
|
||||
"CREATE TABLE t(key serial primary key)",
|
||||
"INSERT INTO t SELECT FROM generate_series(1, 100000)",
|
||||
"SELECT pg_current_wal_insert_lsn()",
|
||||
"INSERT INTO t SELECT FROM generate_series(1, 100000)",
|
||||
]
|
||||
)
|
||||
lsn = Lsn(res[2][0][0])
|
||||
|
||||
# Use `failpoint=sleep` and `threading` to make the GC iteration triggers *before* the
|
||||
# branch creation task but the individual timeline GC iteration happens *after*
|
||||
# the branch creation task.
|
||||
pageserver_http_client.configure_failpoints(("before-timeline-gc", "sleep(2000)"))
|
||||
|
||||
def do_gc():
|
||||
pageserver_http_client.timeline_gc(tenant, b0, 0)
|
||||
|
||||
thread = threading.Thread(target=do_gc, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# because of network latency and other factors, GC iteration might be processed
|
||||
# after the `create_branch` request. Add a sleep here to make sure that GC is
|
||||
# always processed before.
|
||||
time.sleep(1.0)
|
||||
|
||||
# The starting LSN is invalid as the corresponding record is scheduled to be removed by in-queue GC.
|
||||
with pytest.raises(Exception, match="invalid branch start lsn: .*"):
|
||||
env.neon_cli.create_branch("b1", "b0", tenant_id=tenant, ancestor_start_lsn=lsn)
|
||||
|
||||
thread.join()
|
||||
123
test_runner/regress/test_branch_behind.py
Normal file
123
test_runner/regress/test_branch_behind.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder
|
||||
from fixtures.types import Lsn, TimelineId
|
||||
from fixtures.utils import print_gc_result, query_scalar
|
||||
|
||||
|
||||
#
|
||||
# Create a couple of branches off the main branch, at a historical point in time.
|
||||
#
|
||||
def test_branch_behind(neon_env_builder: NeonEnvBuilder):
|
||||
# Disable pitr, because here we want to test branch creation after GC
|
||||
neon_env_builder.pageserver_config_override = "tenant_config={pitr_interval = '0 sec'}"
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
# Branch at the point where only 100 rows were inserted
|
||||
env.neon_cli.create_branch("test_branch_behind")
|
||||
pgmain = env.postgres.create_start("test_branch_behind")
|
||||
log.info("postgres is running on 'test_branch_behind' branch")
|
||||
|
||||
main_cur = pgmain.connect().cursor()
|
||||
|
||||
timeline = TimelineId(query_scalar(main_cur, "SHOW neon.timeline_id"))
|
||||
|
||||
# Create table, and insert the first 100 rows
|
||||
main_cur.execute("CREATE TABLE foo (t text)")
|
||||
|
||||
# keep some early lsn to test branch creation on out of date lsn
|
||||
gced_lsn = Lsn(query_scalar(main_cur, "SELECT pg_current_wal_insert_lsn()"))
|
||||
|
||||
main_cur.execute(
|
||||
"""
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 100) g
|
||||
"""
|
||||
)
|
||||
lsn_a = Lsn(query_scalar(main_cur, "SELECT pg_current_wal_insert_lsn()"))
|
||||
log.info(f"LSN after 100 rows: {lsn_a}")
|
||||
|
||||
# Insert some more rows. (This generates enough WAL to fill a few segments.)
|
||||
main_cur.execute(
|
||||
"""
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 200000) g
|
||||
"""
|
||||
)
|
||||
lsn_b = Lsn(query_scalar(main_cur, "SELECT pg_current_wal_insert_lsn()"))
|
||||
log.info(f"LSN after 200100 rows: {lsn_b}")
|
||||
|
||||
# Branch at the point where only 100 rows were inserted
|
||||
env.neon_cli.create_branch(
|
||||
"test_branch_behind_hundred", "test_branch_behind", ancestor_start_lsn=lsn_a
|
||||
)
|
||||
|
||||
# Insert many more rows. This generates enough WAL to fill a few segments.
|
||||
main_cur.execute(
|
||||
"""
|
||||
INSERT INTO foo
|
||||
SELECT 'long string to consume some space' || g
|
||||
FROM generate_series(1, 200000) g
|
||||
"""
|
||||
)
|
||||
lsn_c = Lsn(query_scalar(main_cur, "SELECT pg_current_wal_insert_lsn()"))
|
||||
|
||||
log.info(f"LSN after 400100 rows: {lsn_c}")
|
||||
|
||||
# Branch at the point where only 200100 rows were inserted
|
||||
env.neon_cli.create_branch(
|
||||
"test_branch_behind_more", "test_branch_behind", ancestor_start_lsn=lsn_b
|
||||
)
|
||||
|
||||
pg_hundred = env.postgres.create_start("test_branch_behind_hundred")
|
||||
pg_more = env.postgres.create_start("test_branch_behind_more")
|
||||
|
||||
# On the 'hundred' branch, we should see only 100 rows
|
||||
hundred_cur = pg_hundred.connect().cursor()
|
||||
assert query_scalar(hundred_cur, "SELECT count(*) FROM foo") == 100
|
||||
|
||||
# On the 'more' branch, we should see 100200 rows
|
||||
more_cur = pg_more.connect().cursor()
|
||||
assert query_scalar(more_cur, "SELECT count(*) FROM foo") == 200100
|
||||
|
||||
# All the rows are visible on the main branch
|
||||
assert query_scalar(main_cur, "SELECT count(*) FROM foo") == 400100
|
||||
|
||||
# Check bad lsn's for branching
|
||||
|
||||
# branch at segment boundary
|
||||
env.neon_cli.create_branch(
|
||||
"test_branch_segment_boundary", "test_branch_behind", ancestor_start_lsn=Lsn("0/3000000")
|
||||
)
|
||||
pg = env.postgres.create_start("test_branch_segment_boundary")
|
||||
assert pg.safe_psql("SELECT 1")[0][0] == 1
|
||||
|
||||
# branch at pre-initdb lsn
|
||||
with pytest.raises(Exception, match="invalid branch start lsn: .*"):
|
||||
env.neon_cli.create_branch("test_branch_preinitdb", ancestor_start_lsn=Lsn("0/42"))
|
||||
|
||||
# branch at pre-ancestor lsn
|
||||
with pytest.raises(Exception, match="less than timeline ancestor lsn"):
|
||||
env.neon_cli.create_branch(
|
||||
"test_branch_preinitdb", "test_branch_behind", ancestor_start_lsn=Lsn("0/42")
|
||||
)
|
||||
|
||||
# check that we cannot create branch based on garbage collected data
|
||||
with env.pageserver.http_client() as pageserver_http:
|
||||
gc_result = pageserver_http.timeline_gc(env.initial_tenant, timeline, 0)
|
||||
print_gc_result(gc_result)
|
||||
|
||||
with pytest.raises(Exception, match="invalid branch start lsn: .*"):
|
||||
# this gced_lsn is pretty random, so if gc is disabled this woudln't fail
|
||||
env.neon_cli.create_branch(
|
||||
"test_branch_create_fail", "test_branch_behind", ancestor_start_lsn=gced_lsn
|
||||
)
|
||||
|
||||
# check that after gc everything is still there
|
||||
assert query_scalar(hundred_cur, "SELECT count(*) FROM foo") == 100
|
||||
|
||||
assert query_scalar(more_cur, "SELECT count(*) FROM foo") == 200100
|
||||
|
||||
assert query_scalar(main_cur, "SELECT count(*) FROM foo") == 400100
|
||||
128
test_runner/regress/test_branching.py
Normal file
128
test_runner/regress/test_branching.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnv, PgBin, Postgres
|
||||
from fixtures.types import Lsn
|
||||
from fixtures.utils import query_scalar
|
||||
from performance.test_perf_pgbench import get_scales_matrix
|
||||
|
||||
|
||||
# Test branch creation
|
||||
#
|
||||
# This test spawns pgbench in a thread in the background, and creates a branch while
|
||||
# pgbench is running. Then it launches pgbench on the new branch, and creates another branch.
|
||||
# Repeat `n_branches` times.
|
||||
#
|
||||
# If 'ty' == 'cascade', each branch is created from the previous branch, so that you end
|
||||
# up with a branch of a branch of a branch ... of a branch. With 'ty' == 'flat',
|
||||
# each branch is created from the root.
|
||||
@pytest.mark.parametrize("n_branches", [10])
|
||||
@pytest.mark.parametrize("scale", get_scales_matrix(1))
|
||||
@pytest.mark.parametrize("ty", ["cascade", "flat"])
|
||||
def test_branching_with_pgbench(
|
||||
neon_simple_env: NeonEnv, pg_bin: PgBin, n_branches: int, scale: int, ty: str
|
||||
):
|
||||
env = neon_simple_env
|
||||
|
||||
# Use aggressive GC and checkpoint settings, so that we also exercise GC during the test
|
||||
tenant, _ = env.neon_cli.create_tenant(
|
||||
conf={
|
||||
"gc_period": "5 s",
|
||||
"gc_horizon": f"{1024 ** 2}",
|
||||
"checkpoint_distance": f"{1024 ** 2}",
|
||||
"compaction_target_size": f"{1024 ** 2}",
|
||||
# set PITR interval to be small, so we can do GC
|
||||
"pitr_interval": "5 s",
|
||||
}
|
||||
)
|
||||
|
||||
def run_pgbench(pg: Postgres):
|
||||
connstr = pg.connstr()
|
||||
|
||||
log.info(f"Start a pgbench workload on pg {connstr}")
|
||||
|
||||
pg_bin.run_capture(["pgbench", "-i", f"-s{scale}", connstr])
|
||||
pg_bin.run_capture(["pgbench", "-T15", connstr])
|
||||
|
||||
env.neon_cli.create_branch("b0", tenant_id=tenant)
|
||||
pgs: List[Postgres] = []
|
||||
pgs.append(env.postgres.create_start("b0", tenant_id=tenant))
|
||||
|
||||
threads: List[threading.Thread] = []
|
||||
threads.append(threading.Thread(target=run_pgbench, args=(pgs[0],), daemon=True))
|
||||
threads[-1].start()
|
||||
|
||||
thread_limit = 4
|
||||
|
||||
for i in range(n_branches):
|
||||
# random a delay between [0, 5]
|
||||
delay = random.random() * 5
|
||||
time.sleep(delay)
|
||||
log.info(f"Sleep {delay}s")
|
||||
|
||||
# If the number of concurrent threads exceeds a threshold, wait for
|
||||
# all the threads to finish before spawning a new one. Because the
|
||||
# regression tests in this directory are run concurrently in CI, we
|
||||
# want to avoid the situation that one test exhausts resources for
|
||||
# other tests.
|
||||
if len(threads) >= thread_limit:
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
threads = []
|
||||
|
||||
if ty == "cascade":
|
||||
env.neon_cli.create_branch("b{}".format(i + 1), "b{}".format(i), tenant_id=tenant)
|
||||
else:
|
||||
env.neon_cli.create_branch("b{}".format(i + 1), "b0", tenant_id=tenant)
|
||||
|
||||
pgs.append(env.postgres.create_start("b{}".format(i + 1), tenant_id=tenant))
|
||||
|
||||
threads.append(threading.Thread(target=run_pgbench, args=(pgs[-1],), daemon=True))
|
||||
threads[-1].start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
for pg in pgs:
|
||||
res = pg.safe_psql("SELECT count(*) from pgbench_accounts")
|
||||
assert res[0] == (100000 * scale,)
|
||||
|
||||
|
||||
# Test branching from an "unnormalized" LSN.
|
||||
#
|
||||
# Context:
|
||||
# When doing basebackup for a newly created branch, pageserver generates
|
||||
# 'pg_control' file to bootstrap WAL segment by specifying the redo position
|
||||
# a "normalized" LSN based on the timeline's starting LSN:
|
||||
#
|
||||
# checkpoint.redo = normalize_lsn(self.lsn, pg_constants::WAL_SEGMENT_SIZE).0;
|
||||
#
|
||||
# This test checks if the pageserver is able to handle a "unnormalized" starting LSN.
|
||||
#
|
||||
# Related: see discussion in https://github.com/neondatabase/neon/pull/2143#issuecomment-1209092186
|
||||
def test_branching_unnormalized_start_lsn(neon_simple_env: NeonEnv, pg_bin: PgBin):
|
||||
XLOG_BLCKSZ = 8192
|
||||
|
||||
env = neon_simple_env
|
||||
|
||||
env.neon_cli.create_branch("b0")
|
||||
pg0 = env.postgres.create_start("b0")
|
||||
|
||||
pg_bin.run_capture(["pgbench", "-i", pg0.connstr()])
|
||||
|
||||
with pg0.cursor() as cur:
|
||||
curr_lsn = Lsn(query_scalar(cur, "SELECT pg_current_wal_flush_lsn()"))
|
||||
|
||||
# Specify the `start_lsn` as a number that is divided by `XLOG_BLCKSZ`
|
||||
# and is smaller than `curr_lsn`.
|
||||
start_lsn = Lsn((int(curr_lsn) - XLOG_BLCKSZ) // XLOG_BLCKSZ * XLOG_BLCKSZ)
|
||||
|
||||
log.info(f"Branching b1 from b0 starting at lsn {start_lsn}...")
|
||||
env.neon_cli.create_branch("b1", "b0", ancestor_start_lsn=start_lsn)
|
||||
pg1 = env.postgres.create_start("b1")
|
||||
|
||||
pg_bin.run_capture(["pgbench", "-i", pg1.connstr()])
|
||||
166
test_runner/regress/test_broken_timeline.py
Normal file
166
test_runner/regress/test_broken_timeline.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import concurrent.futures
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnv, NeonEnvBuilder, Postgres
|
||||
from fixtures.types import TenantId, TimelineId
|
||||
|
||||
|
||||
# Test restarting page server, while safekeeper and compute node keep
|
||||
# running.
|
||||
def test_broken_timeline(neon_env_builder: NeonEnvBuilder):
|
||||
# One safekeeper is enough for this test.
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
tenant_timelines: List[Tuple[TenantId, TimelineId, Postgres]] = []
|
||||
|
||||
for n in range(4):
|
||||
tenant_id, timeline_id = env.neon_cli.create_tenant()
|
||||
|
||||
pg = env.postgres.create_start("main", tenant_id=tenant_id)
|
||||
with pg.cursor() as cur:
|
||||
cur.execute("CREATE TABLE t(key int primary key, value text)")
|
||||
cur.execute("INSERT INTO t SELECT generate_series(1,100), 'payload'")
|
||||
pg.stop()
|
||||
tenant_timelines.append((tenant_id, timeline_id, pg))
|
||||
|
||||
# Stop the pageserver
|
||||
env.pageserver.stop()
|
||||
|
||||
# Leave the first timeline alone, but corrupt the others in different ways
|
||||
(tenant0, timeline0, pg0) = tenant_timelines[0]
|
||||
log.info(f"Timeline {tenant0}/{timeline0} is left intact")
|
||||
|
||||
(tenant1, timeline1, pg1) = tenant_timelines[1]
|
||||
metadata_path = f"{env.repo_dir}/tenants/{tenant1}/timelines/{timeline1}/metadata"
|
||||
f = open(metadata_path, "w")
|
||||
f.write("overwritten with garbage!")
|
||||
f.close()
|
||||
log.info(f"Timeline {tenant1}/{timeline1} got its metadata spoiled")
|
||||
|
||||
(tenant2, timeline2, pg2) = tenant_timelines[2]
|
||||
timeline_path = f"{env.repo_dir}/tenants/{tenant2}/timelines/{timeline2}/"
|
||||
for filename in os.listdir(timeline_path):
|
||||
if filename.startswith("00000"):
|
||||
# Looks like a layer file. Remove it
|
||||
os.remove(f"{timeline_path}/{filename}")
|
||||
log.info(
|
||||
f"Timeline {tenant2}/{timeline2} got its layer files removed (no remote storage enabled)"
|
||||
)
|
||||
|
||||
(tenant3, timeline3, pg3) = tenant_timelines[3]
|
||||
timeline_path = f"{env.repo_dir}/tenants/{tenant3}/timelines/{timeline3}/"
|
||||
for filename in os.listdir(timeline_path):
|
||||
if filename.startswith("00000"):
|
||||
# Looks like a layer file. Corrupt it
|
||||
f = open(f"{timeline_path}/{filename}", "w")
|
||||
f.write("overwritten with garbage!")
|
||||
f.close()
|
||||
log.info(f"Timeline {tenant3}/{timeline3} got its layer files spoiled")
|
||||
|
||||
env.pageserver.start()
|
||||
|
||||
# Tenant 0 should still work
|
||||
pg0.start()
|
||||
assert pg0.safe_psql("SELECT COUNT(*) FROM t")[0][0] == 100
|
||||
|
||||
# But all others are broken
|
||||
|
||||
# First timeline would not get loaded into pageserver due to corrupt metadata file
|
||||
with pytest.raises(Exception, match=f"Timeline {tenant1}/{timeline1} was not found") as err:
|
||||
pg1.start()
|
||||
log.info(f"compute startup failed eagerly for timeline with corrupt metadata: {err}")
|
||||
|
||||
# Second timeline has no ancestors, only the metadata file and no layer files
|
||||
# We don't have the remote storage enabled, which means timeline is in an incorrect state,
|
||||
# it's not loaded at all
|
||||
with pytest.raises(Exception, match=f"Timeline {tenant2}/{timeline2} was not found") as err:
|
||||
pg2.start()
|
||||
log.info(f"compute startup failed eagerly for timeline with corrupt metadata: {err}")
|
||||
|
||||
# Yet other timelines will fail when their layers will be queried during basebackup: we don't check layer file contents on startup, when loading the timeline
|
||||
for n in range(3, 4):
|
||||
(bad_tenant, bad_timeline, pg) = tenant_timelines[n]
|
||||
with pytest.raises(Exception, match="extracting base backup failed") as err:
|
||||
pg.start()
|
||||
log.info(
|
||||
f"compute startup failed lazily for timeline {bad_tenant}/{bad_timeline} with corrupt layers, during basebackup preparation: {err}"
|
||||
)
|
||||
|
||||
|
||||
def test_create_multiple_timelines_parallel(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
|
||||
tenant_id, _ = env.neon_cli.create_tenant()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
env.neon_cli.create_timeline, f"test-create-multiple-timelines-{i}", tenant_id
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
for future in futures:
|
||||
future.result()
|
||||
|
||||
|
||||
def test_timeline_init_break_before_checkpoint(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
pageserver_http = env.pageserver.http_client()
|
||||
|
||||
tenant_id, _ = env.neon_cli.create_tenant()
|
||||
|
||||
timelines_dir = env.repo_dir / "tenants" / str(tenant_id) / "timelines"
|
||||
old_tenant_timelines = env.neon_cli.list_timelines(tenant_id)
|
||||
initial_timeline_dirs = [d for d in timelines_dir.iterdir()]
|
||||
|
||||
# Introduce failpoint during timeline init (some intermediate files are on disk), before it's checkpointed.
|
||||
pageserver_http.configure_failpoints(("before-checkpoint-new-timeline", "return"))
|
||||
with pytest.raises(Exception, match="before-checkpoint-new-timeline"):
|
||||
_ = env.neon_cli.create_timeline("test_timeline_init_break_before_checkpoint", tenant_id)
|
||||
|
||||
# Restart the page server
|
||||
env.neon_cli.pageserver_stop(immediate=True)
|
||||
env.neon_cli.pageserver_start()
|
||||
|
||||
# Creating the timeline didn't finish. The other timelines on tenant should still be present and work normally.
|
||||
new_tenant_timelines = env.neon_cli.list_timelines(tenant_id)
|
||||
assert (
|
||||
new_tenant_timelines == old_tenant_timelines
|
||||
), f"Pageserver after restart should ignore non-initialized timelines for tenant {tenant_id}"
|
||||
|
||||
timeline_dirs = [d for d in timelines_dir.iterdir()]
|
||||
assert (
|
||||
timeline_dirs == initial_timeline_dirs
|
||||
), "pageserver should clean its temp timeline files on timeline creation failure"
|
||||
|
||||
|
||||
def test_timeline_create_break_after_uninit_mark(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
pageserver_http = env.pageserver.http_client()
|
||||
|
||||
tenant_id, _ = env.neon_cli.create_tenant()
|
||||
|
||||
timelines_dir = env.repo_dir / "tenants" / str(tenant_id) / "timelines"
|
||||
old_tenant_timelines = env.neon_cli.list_timelines(tenant_id)
|
||||
initial_timeline_dirs = [d for d in timelines_dir.iterdir()]
|
||||
|
||||
# Introduce failpoint when creating a new timeline uninit mark, before any other files were created
|
||||
pageserver_http.configure_failpoints(("after-timeline-uninit-mark-creation", "return"))
|
||||
with pytest.raises(Exception, match="after-timeline-uninit-mark-creation"):
|
||||
_ = env.neon_cli.create_timeline("test_timeline_create_break_after_uninit_mark", tenant_id)
|
||||
|
||||
# Creating the timeline didn't finish. The other timelines on tenant should still be present and work normally.
|
||||
# "New" timeline is not present in the list, allowing pageserver to retry the same request
|
||||
new_tenant_timelines = env.neon_cli.list_timelines(tenant_id)
|
||||
assert (
|
||||
new_tenant_timelines == old_tenant_timelines
|
||||
), f"Pageserver after restart should ignore non-initialized timelines for tenant {tenant_id}"
|
||||
|
||||
timeline_dirs = [d for d in timelines_dir.iterdir()]
|
||||
assert (
|
||||
timeline_dirs == initial_timeline_dirs
|
||||
), "pageserver should clean its temp timeline files on timeline creation failure"
|
||||
19
test_runner/regress/test_build_info_metric.py
Normal file
19
test_runner/regress/test_build_info_metric.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fixtures.metrics import parse_metrics
|
||||
from fixtures.neon_fixtures import NeonEnvBuilder, NeonProxy
|
||||
|
||||
|
||||
def test_build_info_metric(neon_env_builder: NeonEnvBuilder, link_proxy: NeonProxy):
|
||||
neon_env_builder.num_safekeepers = 1
|
||||
env = neon_env_builder.init_start()
|
||||
|
||||
parsed_metrics = {}
|
||||
|
||||
parsed_metrics["pageserver"] = parse_metrics(env.pageserver.http_client().get_metrics())
|
||||
parsed_metrics["safekeeper"] = parse_metrics(env.safekeepers[0].http_client().get_metrics_str())
|
||||
parsed_metrics["proxy"] = parse_metrics(link_proxy.get_metrics())
|
||||
|
||||
for component, metrics in parsed_metrics.items():
|
||||
sample = metrics.query_one("libmetrics_build_info")
|
||||
|
||||
assert "revision" in sample.labels
|
||||
assert len(sample.labels["revision"]) > 0
|
||||
70
test_runner/regress/test_clog_truncate.py
Normal file
70
test_runner/regress/test_clog_truncate.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnv
|
||||
from fixtures.utils import query_scalar
|
||||
|
||||
|
||||
#
|
||||
# Test compute node start after clog truncation
|
||||
#
|
||||
def test_clog_truncate(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
env.neon_cli.create_branch("test_clog_truncate", "empty")
|
||||
|
||||
# set aggressive autovacuum to make sure that truncation will happen
|
||||
config = [
|
||||
"autovacuum_max_workers=10",
|
||||
"autovacuum_vacuum_threshold=0",
|
||||
"autovacuum_vacuum_insert_threshold=0",
|
||||
"autovacuum_vacuum_cost_delay=0",
|
||||
"autovacuum_vacuum_cost_limit=10000",
|
||||
"autovacuum_naptime =1s",
|
||||
"autovacuum_freeze_max_age=100000",
|
||||
]
|
||||
|
||||
pg = env.postgres.create_start("test_clog_truncate", config_lines=config)
|
||||
log.info("postgres is running on test_clog_truncate branch")
|
||||
|
||||
# Install extension containing function needed for test
|
||||
pg.safe_psql("CREATE EXTENSION neon_test_utils")
|
||||
|
||||
# Consume many xids to advance clog
|
||||
with pg.cursor() as cur:
|
||||
cur.execute("select test_consume_xids(1000*1000*10);")
|
||||
log.info("xids consumed")
|
||||
|
||||
# call a checkpoint to trigger TruncateSubtrans
|
||||
cur.execute("CHECKPOINT;")
|
||||
|
||||
# ensure WAL flush
|
||||
cur.execute("select txid_current()")
|
||||
log.info(cur.fetchone())
|
||||
|
||||
# wait for autovacuum to truncate the pg_xact
|
||||
# XXX Is it worth to add a timeout here?
|
||||
pg_xact_0000_path = os.path.join(pg.pg_xact_dir_path(), "0000")
|
||||
log.info(f"pg_xact_0000_path = {pg_xact_0000_path}")
|
||||
|
||||
while os.path.isfile(pg_xact_0000_path):
|
||||
log.info(f"file exists. wait for truncation: {pg_xact_0000_path=}")
|
||||
time.sleep(5)
|
||||
|
||||
# checkpoint to advance latest lsn
|
||||
with pg.cursor() as cur:
|
||||
cur.execute("CHECKPOINT;")
|
||||
lsn_after_truncation = query_scalar(cur, "select pg_current_wal_insert_lsn()")
|
||||
|
||||
# create new branch after clog truncation and start a compute node on it
|
||||
log.info(f"create branch at lsn_after_truncation {lsn_after_truncation}")
|
||||
env.neon_cli.create_branch(
|
||||
"test_clog_truncate_new", "test_clog_truncate", ancestor_start_lsn=lsn_after_truncation
|
||||
)
|
||||
pg2 = env.postgres.create_start("test_clog_truncate_new")
|
||||
log.info("postgres is running on test_clog_truncate_new branch")
|
||||
|
||||
# check that new node doesn't contain truncated segment
|
||||
pg_xact_0000_path_new = os.path.join(pg2.pg_xact_dir_path(), "0000")
|
||||
log.info(f"pg_xact_0000_path_new = {pg_xact_0000_path_new}")
|
||||
assert os.path.isfile(pg_xact_0000_path_new) is False
|
||||
53
test_runner/regress/test_close_fds.py
Normal file
53
test_runner/regress/test_close_fds.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os.path
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import NeonEnv
|
||||
|
||||
|
||||
def lsof_path() -> str:
|
||||
path_output = shutil.which("lsof")
|
||||
if path_output is None:
|
||||
raise RuntimeError("lsof not found in PATH")
|
||||
else:
|
||||
return path_output
|
||||
|
||||
|
||||
# Makes sure that `pageserver.pid` is only held by `pageserve` command, not other commands.
|
||||
# This is to test the changes in https://github.com/neondatabase/neon/pull/1834.
|
||||
def test_lsof_pageserver_pid(neon_simple_env: NeonEnv):
|
||||
env = neon_simple_env
|
||||
|
||||
def start_workload():
|
||||
env.neon_cli.create_branch("test_lsof_pageserver_pid")
|
||||
pg = env.postgres.create_start("test_lsof_pageserver_pid")
|
||||
with closing(pg.connect()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("CREATE TABLE foo as SELECT x FROM generate_series(1,100000) x")
|
||||
cur.execute("update foo set x=x+1")
|
||||
|
||||
workload_thread = threading.Thread(target=start_workload, args=(), daemon=True)
|
||||
workload_thread.start()
|
||||
|
||||
path = os.path.join(env.repo_dir, "pageserver.pid")
|
||||
lsof = lsof_path()
|
||||
while workload_thread.is_alive():
|
||||
res = subprocess.run(
|
||||
[lsof, path],
|
||||
check=False,
|
||||
universal_newlines=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# parse the `lsof` command's output to get only the list of commands
|
||||
commands = [line.split(" ")[0] for line in res.stdout.strip().split("\n")[1:]]
|
||||
if len(commands) > 0:
|
||||
log.info(f"lsof commands: {commands}")
|
||||
assert commands == ["pageserve"]
|
||||
|
||||
time.sleep(1.0)
|
||||
356
test_runner/regress/test_compatibility.py
Normal file
356
test_runner/regress/test_compatibility.py
Normal file
@@ -0,0 +1,356 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import toml # TODO: replace with tomllib for Python >= 3.11
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import (
|
||||
NeonCli,
|
||||
NeonEnvBuilder,
|
||||
PageserverHttpClient,
|
||||
PgBin,
|
||||
PortDistributor,
|
||||
wait_for_last_record_lsn,
|
||||
wait_for_upload,
|
||||
)
|
||||
from fixtures.types import Lsn
|
||||
from pytest import FixtureRequest
|
||||
|
||||
#
|
||||
# A test suite that help to prevent unintentionally breaking backward or forward compatibility between Neon releases.
|
||||
# - `test_create_snapshot` a script wrapped in a test that creates a data snapshot.
|
||||
# - `test_backward_compatibility` checks that the current version of Neon can start/read/interract with a data snapshot created by the previous version.
|
||||
# The path to the snapshot is configured by COMPATIBILITY_SNAPSHOT_DIR environment variable.
|
||||
# If the breakage is intentional, the test can be xfaild with setting ALLOW_BACKWARD_COMPATIBILITY_BREAKAGE=true.
|
||||
# - `test_forward_compatibility` checks that a snapshot created by the current version can be started/read/interracted by the previous version of Neon.
|
||||
# Paths to Neon and Postgres are configured by COMPATIBILITY_NEON_BIN and COMPATIBILITY_POSTGRES_DISTRIB_DIR environment variables.
|
||||
# If the breakage is intentional, the test can be xfaild with setting ALLOW_FORWARD_COMPATIBILITY_BREAKAGE=true.
|
||||
#
|
||||
# The file contains a couple of helper functions:
|
||||
# - prepare_snapshot copies the snapshot, cleans it up and makes it ready for the current version of Neon (replaces paths and ports in config files).
|
||||
# - check_neon_works performs the test itself, feel free to add more checks there.
|
||||
#
|
||||
|
||||
|
||||
# Note: if renaming this test, don't forget to update a reference to it in a workflow file:
|
||||
# "Upload compatibility snapshot" step in .github/actions/run-python-test-set/action.yml
|
||||
@pytest.mark.xdist_group("compatibility")
|
||||
@pytest.mark.order(before="test_forward_compatibility")
|
||||
def test_create_snapshot(neon_env_builder: NeonEnvBuilder, pg_bin: PgBin, test_output_dir: Path):
|
||||
# The test doesn't really test anything
|
||||
# it creates a new snapshot for releases after we tested the current version against the previous snapshot in `test_backward_compatibility`.
|
||||
#
|
||||
# There's no cleanup here, it allows to adjust the data in `test_backward_compatibility` itself without re-collecting it.
|
||||
neon_env_builder.pg_version = "14"
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
neon_env_builder.enable_local_fs_remote_storage()
|
||||
|
||||
env = neon_env_builder.init_start()
|
||||
pg = env.postgres.create_start("main")
|
||||
pg_bin.run(["pgbench", "--initialize", "--scale=10", pg.connstr()])
|
||||
pg_bin.run(["pgbench", "--time=60", "--progress=2", pg.connstr()])
|
||||
pg_bin.run(["pg_dumpall", f"--dbname={pg.connstr()}", f"--file={test_output_dir / 'dump.sql'}"])
|
||||
|
||||
snapshot_config = toml.load(test_output_dir / "repo" / "config")
|
||||
tenant_id = snapshot_config["default_tenant_id"]
|
||||
timeline_id = dict(snapshot_config["branch_name_mappings"]["main"])[tenant_id]
|
||||
|
||||
pageserver_http = env.pageserver.http_client()
|
||||
lsn = Lsn(pg.safe_psql("SELECT pg_current_wal_flush_lsn()")[0][0])
|
||||
|
||||
wait_for_last_record_lsn(pageserver_http, tenant_id, timeline_id, lsn)
|
||||
pageserver_http.timeline_checkpoint(tenant_id, timeline_id)
|
||||
wait_for_upload(pageserver_http, tenant_id, timeline_id, lsn)
|
||||
|
||||
env.postgres.stop_all()
|
||||
for sk in env.safekeepers:
|
||||
sk.stop()
|
||||
env.pageserver.stop()
|
||||
|
||||
shutil.copytree(test_output_dir, test_output_dir / "compatibility_snapshot_pg14")
|
||||
# Directory `test_output_dir / "compatibility_snapshot_pg14"` is uploaded to S3 in a workflow, keep the name in sync with it
|
||||
|
||||
|
||||
@pytest.mark.xdist_group("compatibility")
|
||||
@pytest.mark.order(after="test_create_snapshot")
|
||||
def test_backward_compatibility(
|
||||
pg_bin: PgBin,
|
||||
port_distributor: PortDistributor,
|
||||
test_output_dir: Path,
|
||||
neon_binpath: Path,
|
||||
pg_distrib_dir: Path,
|
||||
pg_version: str,
|
||||
request: FixtureRequest,
|
||||
):
|
||||
compatibility_snapshot_dir_env = os.environ.get("COMPATIBILITY_SNAPSHOT_DIR")
|
||||
assert (
|
||||
compatibility_snapshot_dir_env is not None
|
||||
), "COMPATIBILITY_SNAPSHOT_DIR is not set. It should be set to `compatibility_snapshot_pg14` path generateted by test_create_snapshot (ideally generated by the previous version of Neon)"
|
||||
compatibility_snapshot_dir = Path(compatibility_snapshot_dir_env).resolve()
|
||||
|
||||
# Copy the snapshot to current directory, and prepare for the test
|
||||
prepare_snapshot(
|
||||
from_dir=compatibility_snapshot_dir,
|
||||
to_dir=test_output_dir / "compatibility_snapshot",
|
||||
port_distributor=port_distributor,
|
||||
)
|
||||
|
||||
breaking_changes_allowed = (
|
||||
os.environ.get("ALLOW_BACKWARD_COMPATIBILITY_BREAKAGE", "false").lower() == "true"
|
||||
)
|
||||
try:
|
||||
check_neon_works(
|
||||
test_output_dir / "compatibility_snapshot" / "repo",
|
||||
neon_binpath,
|
||||
pg_distrib_dir,
|
||||
pg_version,
|
||||
port_distributor,
|
||||
test_output_dir,
|
||||
pg_bin,
|
||||
request,
|
||||
)
|
||||
except Exception:
|
||||
if breaking_changes_allowed:
|
||||
pytest.xfail(
|
||||
"Breaking changes are allowed by ALLOW_BACKWARD_COMPATIBILITY_BREAKAGE env var"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
assert (
|
||||
not breaking_changes_allowed
|
||||
), "Breaking changes are allowed by ALLOW_BACKWARD_COMPATIBILITY_BREAKAGE, but the test has passed without any breakage"
|
||||
|
||||
|
||||
@pytest.mark.xdist_group("compatibility")
|
||||
@pytest.mark.order(after="test_create_snapshot")
|
||||
def test_forward_compatibility(
|
||||
test_output_dir: Path,
|
||||
port_distributor: PortDistributor,
|
||||
pg_version: str,
|
||||
request: FixtureRequest,
|
||||
):
|
||||
compatibility_neon_bin_env = os.environ.get("COMPATIBILITY_NEON_BIN")
|
||||
assert compatibility_neon_bin_env is not None, (
|
||||
"COMPATIBILITY_NEON_BIN is not set. It should be set to a path with Neon binaries "
|
||||
"(ideally generated by the previous version of Neon)"
|
||||
)
|
||||
compatibility_neon_bin = Path(compatibility_neon_bin_env).resolve()
|
||||
|
||||
compatibility_postgres_distrib_dir_env = os.environ.get("COMPATIBILITY_POSTGRES_DISTRIB_DIR")
|
||||
assert (
|
||||
compatibility_postgres_distrib_dir_env is not None
|
||||
), "COMPATIBILITY_POSTGRES_DISTRIB_DIR is not set. It should be set to a pg_install directrory (ideally generated by the previous version of Neon)"
|
||||
compatibility_postgres_distrib_dir = Path(compatibility_postgres_distrib_dir_env).resolve()
|
||||
|
||||
compatibility_snapshot_dir = (
|
||||
test_output_dir.parent / "test_create_snapshot" / "compatibility_snapshot_pg14"
|
||||
)
|
||||
# Copy the snapshot to current directory, and prepare for the test
|
||||
prepare_snapshot(
|
||||
from_dir=compatibility_snapshot_dir,
|
||||
to_dir=test_output_dir / "compatibility_snapshot",
|
||||
port_distributor=port_distributor,
|
||||
)
|
||||
|
||||
breaking_changes_allowed = (
|
||||
os.environ.get("ALLOW_FORWARD_COMPATIBILITY_BREAKAGE", "false").lower() == "true"
|
||||
)
|
||||
try:
|
||||
check_neon_works(
|
||||
test_output_dir / "compatibility_snapshot" / "repo",
|
||||
compatibility_neon_bin,
|
||||
compatibility_postgres_distrib_dir,
|
||||
pg_version,
|
||||
port_distributor,
|
||||
test_output_dir,
|
||||
PgBin(test_output_dir, compatibility_postgres_distrib_dir, pg_version),
|
||||
request,
|
||||
)
|
||||
except Exception:
|
||||
if breaking_changes_allowed:
|
||||
pytest.xfail(
|
||||
"Breaking changes are allowed by ALLOW_FORWARD_COMPATIBILITY_BREAKAGE env var"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
assert (
|
||||
not breaking_changes_allowed
|
||||
), "Breaking changes are allowed by ALLOW_FORWARD_COMPATIBILITY_BREAKAGE, but the test has passed without any breakage"
|
||||
|
||||
|
||||
def prepare_snapshot(from_dir: Path, to_dir: Path, port_distributor: PortDistributor):
|
||||
assert from_dir.exists(), f"Snapshot '{from_dir}' doesn't exist"
|
||||
assert (from_dir / "repo").exists(), f"Snapshot '{from_dir}' doesn't contain a repo directory"
|
||||
assert (from_dir / "dump.sql").exists(), f"Snapshot '{from_dir}' doesn't contain a dump.sql"
|
||||
|
||||
log.info(f"Copying snapshot from {from_dir} to {to_dir}")
|
||||
shutil.copytree(from_dir, to_dir)
|
||||
|
||||
repo_dir = to_dir / "repo"
|
||||
|
||||
# Remove old logs to avoid confusion in test artifacts
|
||||
for logfile in repo_dir.glob("**/*.log"):
|
||||
logfile.unlink()
|
||||
|
||||
# Remove tenants data for compute
|
||||
for tenant in (repo_dir / "pgdatadirs" / "tenants").glob("*"):
|
||||
shutil.rmtree(tenant)
|
||||
|
||||
# Remove wal-redo temp directory
|
||||
for tenant in (repo_dir / "tenants").glob("*"):
|
||||
shutil.rmtree(tenant / "wal-redo-datadir.___temp")
|
||||
|
||||
# Update paths and ports in config files
|
||||
pageserver_toml = repo_dir / "pageserver.toml"
|
||||
pageserver_config = toml.load(pageserver_toml)
|
||||
pageserver_config["remote_storage"]["local_path"] = repo_dir / "local_fs_remote_storage"
|
||||
pageserver_config["listen_http_addr"] = port_distributor.replace_with_new_port(
|
||||
pageserver_config["listen_http_addr"]
|
||||
)
|
||||
pageserver_config["listen_pg_addr"] = port_distributor.replace_with_new_port(
|
||||
pageserver_config["listen_pg_addr"]
|
||||
)
|
||||
pageserver_config["broker_endpoints"] = [
|
||||
port_distributor.replace_with_new_port(ep) for ep in pageserver_config["broker_endpoints"]
|
||||
]
|
||||
|
||||
with pageserver_toml.open("w") as f:
|
||||
toml.dump(pageserver_config, f)
|
||||
|
||||
snapshot_config_toml = repo_dir / "config"
|
||||
snapshot_config = toml.load(snapshot_config_toml)
|
||||
snapshot_config["etcd_broker"]["broker_endpoints"] = [
|
||||
port_distributor.replace_with_new_port(ep)
|
||||
for ep in snapshot_config["etcd_broker"]["broker_endpoints"]
|
||||
]
|
||||
snapshot_config["pageserver"]["listen_http_addr"] = port_distributor.replace_with_new_port(
|
||||
snapshot_config["pageserver"]["listen_http_addr"]
|
||||
)
|
||||
snapshot_config["pageserver"]["listen_pg_addr"] = port_distributor.replace_with_new_port(
|
||||
snapshot_config["pageserver"]["listen_pg_addr"]
|
||||
)
|
||||
for sk in snapshot_config["safekeepers"]:
|
||||
sk["http_port"] = port_distributor.replace_with_new_port(sk["http_port"])
|
||||
sk["pg_port"] = port_distributor.replace_with_new_port(sk["pg_port"])
|
||||
|
||||
with (snapshot_config_toml).open("w") as f:
|
||||
toml.dump(snapshot_config, f)
|
||||
|
||||
# Ensure that snapshot doesn't contain references to the original path
|
||||
rv = subprocess.run(
|
||||
[
|
||||
"grep",
|
||||
"--recursive",
|
||||
"--binary-file=without-match",
|
||||
"--files-with-matches",
|
||||
"test_create_snapshot/repo",
|
||||
str(repo_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert (
|
||||
rv.returncode != 0
|
||||
), f"there're files referencing `test_create_snapshot/repo`, this path should be replaced with {repo_dir}:\n{rv.stdout}"
|
||||
|
||||
|
||||
def check_neon_works(
|
||||
repo_dir: Path,
|
||||
neon_binpath: Path,
|
||||
pg_distrib_dir: Path,
|
||||
pg_version: str,
|
||||
port_distributor: PortDistributor,
|
||||
test_output_dir: Path,
|
||||
pg_bin: PgBin,
|
||||
request: FixtureRequest,
|
||||
):
|
||||
snapshot_config_toml = repo_dir / "config"
|
||||
snapshot_config = toml.load(snapshot_config_toml)
|
||||
snapshot_config["neon_distrib_dir"] = str(neon_binpath)
|
||||
snapshot_config["postgres_distrib_dir"] = str(pg_distrib_dir)
|
||||
with (snapshot_config_toml).open("w") as f:
|
||||
toml.dump(snapshot_config, f)
|
||||
|
||||
# TODO: replace with NeonEnvBuilder / NeonEnv
|
||||
config: Any = type("NeonEnvStub", (object,), {})
|
||||
config.rust_log_override = None
|
||||
config.repo_dir = repo_dir
|
||||
config.pg_version = pg_version
|
||||
config.initial_tenant = snapshot_config["default_tenant_id"]
|
||||
config.neon_binpath = neon_binpath
|
||||
config.pg_distrib_dir = pg_distrib_dir
|
||||
|
||||
cli = NeonCli(config)
|
||||
cli.raw_cli(["start"])
|
||||
request.addfinalizer(lambda: cli.raw_cli(["stop"]))
|
||||
|
||||
pg_port = port_distributor.get_port()
|
||||
cli.pg_start("main", port=pg_port)
|
||||
request.addfinalizer(lambda: cli.pg_stop("main"))
|
||||
|
||||
connstr = f"host=127.0.0.1 port={pg_port} user=cloud_admin dbname=postgres"
|
||||
pg_bin.run(["pg_dumpall", f"--dbname={connstr}", f"--file={test_output_dir / 'dump.sql'}"])
|
||||
initial_dump_differs = dump_differs(
|
||||
repo_dir.parent / "dump.sql",
|
||||
test_output_dir / "dump.sql",
|
||||
test_output_dir / "dump.filediff",
|
||||
)
|
||||
|
||||
# Check that project can be recovered from WAL
|
||||
# loosely based on https://github.com/neondatabase/cloud/wiki/Recovery-from-WAL
|
||||
tenant_id = snapshot_config["default_tenant_id"]
|
||||
timeline_id = dict(snapshot_config["branch_name_mappings"]["main"])[tenant_id]
|
||||
pageserver_port = snapshot_config["pageserver"]["listen_http_addr"].split(":")[-1]
|
||||
auth_token = snapshot_config["pageserver"]["auth_token"]
|
||||
pageserver_http = PageserverHttpClient(
|
||||
port=pageserver_port,
|
||||
is_testing_enabled_or_skip=lambda: True, # TODO: check if testing really enabled
|
||||
auth_token=auth_token,
|
||||
)
|
||||
|
||||
shutil.rmtree(repo_dir / "local_fs_remote_storage")
|
||||
pageserver_http.timeline_delete(tenant_id, timeline_id)
|
||||
pageserver_http.timeline_create(tenant_id, timeline_id)
|
||||
pg_bin.run(
|
||||
["pg_dumpall", f"--dbname={connstr}", f"--file={test_output_dir / 'dump-from-wal.sql'}"]
|
||||
)
|
||||
# The assert itself deferred to the end of the test
|
||||
# to allow us to perform checks that change data before failing
|
||||
dump_from_wal_differs = dump_differs(
|
||||
test_output_dir / "dump.sql",
|
||||
test_output_dir / "dump-from-wal.sql",
|
||||
test_output_dir / "dump-from-wal.filediff",
|
||||
)
|
||||
|
||||
# Check that we can interract with the data
|
||||
pg_bin.run(["pgbench", "--time=10", "--progress=2", connstr])
|
||||
|
||||
assert not dump_from_wal_differs, "dump from WAL differs"
|
||||
assert not initial_dump_differs, "initial dump differs"
|
||||
|
||||
|
||||
def dump_differs(first: Path, second: Path, output: Path) -> bool:
|
||||
"""
|
||||
Runs diff(1) command on two SQL dumps and write the output to the given output file.
|
||||
Returns True if the dumps differ, False otherwise.
|
||||
"""
|
||||
|
||||
with output.open("w") as stdout:
|
||||
rv = subprocess.run(
|
||||
[
|
||||
"diff",
|
||||
"--unified", # Make diff output more readable
|
||||
"--ignore-matching-lines=^--", # Ignore changes in comments
|
||||
"--ignore-blank-lines",
|
||||
str(first),
|
||||
str(second),
|
||||
],
|
||||
stdout=stdout,
|
||||
)
|
||||
|
||||
return rv.returncode != 0
|
||||
203
test_runner/regress/test_compute_ctl.py
Normal file
203
test_runner/regress/test_compute_ctl.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import os
|
||||
from subprocess import TimeoutExpired
|
||||
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import ComputeCtl, NeonEnvBuilder, PgBin
|
||||
|
||||
|
||||
# Test that compute_ctl works and prints "--sync-safekeepers" logs.
|
||||
def test_sync_safekeepers_logs(neon_env_builder: NeonEnvBuilder, pg_bin: PgBin):
|
||||
neon_env_builder.num_safekeepers = 3
|
||||
env = neon_env_builder.init_start()
|
||||
ctl = ComputeCtl(env)
|
||||
|
||||
env.neon_cli.create_branch("test_compute_ctl", "main")
|
||||
pg = env.postgres.create_start("test_compute_ctl")
|
||||
pg.safe_psql("CREATE TABLE t(key int primary key, value text)")
|
||||
|
||||
with open(pg.config_file_path(), "r") as f:
|
||||
cfg_lines = f.readlines()
|
||||
cfg_map = {}
|
||||
for line in cfg_lines:
|
||||
if "=" in line:
|
||||
k, v = line.split("=")
|
||||
cfg_map[k] = v.strip("\n '\"")
|
||||
log.info(f"postgres config: {cfg_map}")
|
||||
pgdata = pg.pg_data_dir_path()
|
||||
pg_bin_path = os.path.join(pg_bin.pg_bin_path, "postgres")
|
||||
|
||||
pg.stop_and_destroy()
|
||||
|
||||
spec = (
|
||||
"""
|
||||
{
|
||||
"format_version": 1.0,
|
||||
|
||||
"timestamp": "2021-05-23T18:25:43.511Z",
|
||||
"operation_uuid": "0f657b36-4b0f-4a2d-9c2e-1dcd615e7d8b",
|
||||
|
||||
"cluster": {
|
||||
"cluster_id": "test-cluster-42",
|
||||
"name": "Neon Test",
|
||||
"state": "restarted",
|
||||
"roles": [
|
||||
],
|
||||
"databases": [
|
||||
],
|
||||
"settings": [
|
||||
{
|
||||
"name": "fsync",
|
||||
"value": "off",
|
||||
"vartype": "bool"
|
||||
},
|
||||
{
|
||||
"name": "wal_level",
|
||||
"value": "replica",
|
||||
"vartype": "enum"
|
||||
},
|
||||
{
|
||||
"name": "hot_standby",
|
||||
"value": "on",
|
||||
"vartype": "bool"
|
||||
},
|
||||
{
|
||||
"name": "neon.safekeepers",
|
||||
"value": """
|
||||
+ f'"{cfg_map["neon.safekeepers"]}"'
|
||||
+ """,
|
||||
"vartype": "string"
|
||||
},
|
||||
{
|
||||
"name": "wal_log_hints",
|
||||
"value": "on",
|
||||
"vartype": "bool"
|
||||
},
|
||||
{
|
||||
"name": "log_connections",
|
||||
"value": "on",
|
||||
"vartype": "bool"
|
||||
},
|
||||
{
|
||||
"name": "shared_buffers",
|
||||
"value": "32768",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"value": """
|
||||
+ f'"{cfg_map["port"]}"'
|
||||
+ """,
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "max_connections",
|
||||
"value": "100",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "max_wal_senders",
|
||||
"value": "10",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "listen_addresses",
|
||||
"value": "0.0.0.0",
|
||||
"vartype": "string"
|
||||
},
|
||||
{
|
||||
"name": "wal_sender_timeout",
|
||||
"value": "0",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "password_encryption",
|
||||
"value": "md5",
|
||||
"vartype": "enum"
|
||||
},
|
||||
{
|
||||
"name": "maintenance_work_mem",
|
||||
"value": "65536",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "max_parallel_workers",
|
||||
"value": "8",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "max_worker_processes",
|
||||
"value": "8",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "neon.tenant_id",
|
||||
"value": """
|
||||
+ f'"{cfg_map["neon.tenant_id"]}"'
|
||||
+ """,
|
||||
"vartype": "string"
|
||||
},
|
||||
{
|
||||
"name": "max_replication_slots",
|
||||
"value": "10",
|
||||
"vartype": "integer"
|
||||
},
|
||||
{
|
||||
"name": "neon.timeline_id",
|
||||
"value": """
|
||||
+ f'"{cfg_map["neon.timeline_id"]}"'
|
||||
+ """,
|
||||
"vartype": "string"
|
||||
},
|
||||
{
|
||||
"name": "shared_preload_libraries",
|
||||
"value": "neon",
|
||||
"vartype": "string"
|
||||
},
|
||||
{
|
||||
"name": "synchronous_standby_names",
|
||||
"value": "walproposer",
|
||||
"vartype": "string"
|
||||
},
|
||||
{
|
||||
"name": "neon.pageserver_connstring",
|
||||
"value": """
|
||||
+ f'"{cfg_map["neon.pageserver_connstring"]}"'
|
||||
+ """,
|
||||
"vartype": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"delta_operations": [
|
||||
]
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
ps_connstr = cfg_map["neon.pageserver_connstring"]
|
||||
log.info(f"ps_connstr: {ps_connstr}, pgdata: {pgdata}")
|
||||
|
||||
# run compute_ctl and wait for 10s
|
||||
try:
|
||||
ctl.raw_cli(
|
||||
["--connstr", ps_connstr, "--pgdata", pgdata, "--spec", spec, "--pgbin", pg_bin_path],
|
||||
timeout=10,
|
||||
)
|
||||
except TimeoutExpired as exc:
|
||||
ctl_logs = exc.stderr.decode("utf-8")
|
||||
log.info("compute_ctl output:\n" + ctl_logs)
|
||||
|
||||
start = "starting safekeepers syncing"
|
||||
end = "safekeepers synced at LSN"
|
||||
start_pos = ctl_logs.index(start)
|
||||
assert start_pos != -1
|
||||
end_pos = ctl_logs.index(end, start_pos)
|
||||
assert end_pos != -1
|
||||
sync_safekeepers_logs = ctl_logs[start_pos : end_pos + len(end)]
|
||||
log.info("sync_safekeepers_logs:\n" + sync_safekeepers_logs)
|
||||
|
||||
# assert that --sync-safekeepers logs are present in the output
|
||||
assert "connecting with node" in sync_safekeepers_logs
|
||||
assert "connected with node" in sync_safekeepers_logs
|
||||
assert "proposer connected to quorum (2)" in sync_safekeepers_logs
|
||||
assert "got votes from majority (2)" in sync_safekeepers_logs
|
||||
assert "sending elected msg to node" in sync_safekeepers_logs
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user