gapwatch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gapwatch-0.1.0/.github/workflows/ci.yml +39 -0
- gapwatch-0.1.0/.github/workflows/publish.yml +20 -0
- gapwatch-0.1.0/.gitignore +11 -0
- gapwatch-0.1.0/CONTRIBUTING.md +34 -0
- gapwatch-0.1.0/LICENSE +21 -0
- gapwatch-0.1.0/PKG-INFO +125 -0
- gapwatch-0.1.0/README.md +102 -0
- gapwatch-0.1.0/pyproject.toml +49 -0
- gapwatch-0.1.0/src/gapwatch/__init__.py +3 -0
- gapwatch-0.1.0/src/gapwatch/__main__.py +5 -0
- gapwatch-0.1.0/src/gapwatch/checker.py +133 -0
- gapwatch-0.1.0/src/gapwatch/cli.py +148 -0
- gapwatch-0.1.0/src/gapwatch/listen_notify.py +217 -0
- gapwatch-0.1.0/tests/test_checker.py +60 -0
- gapwatch-0.1.0/tests/test_listen_notify.py +59 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
17
|
+
services:
|
|
18
|
+
postgres:
|
|
19
|
+
image: postgres:16
|
|
20
|
+
env:
|
|
21
|
+
POSTGRES_PASSWORD: postgres
|
|
22
|
+
ports:
|
|
23
|
+
- 5432:5432
|
|
24
|
+
options: >-
|
|
25
|
+
--health-cmd pg_isready
|
|
26
|
+
--health-interval 5s
|
|
27
|
+
--health-timeout 5s
|
|
28
|
+
--health-retries 10
|
|
29
|
+
env:
|
|
30
|
+
GAPWATCH_TEST_DSN: postgresql://postgres:postgres@localhost:5432/postgres
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
- uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ matrix.python-version }}
|
|
36
|
+
- run: pip install -e ".[dev]"
|
|
37
|
+
- run: ruff check .
|
|
38
|
+
- run: ruff format --check .
|
|
39
|
+
- run: pytest -q
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: pip install build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for helping. Bug reports, new sources and better fault types are all welcome.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git clone https://github.com/Dakokodaripon/gapwatch
|
|
9
|
+
cd gapwatch
|
|
10
|
+
pip install -e ".[dev]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The checker tests run without a database. The Postgres tests need a database you can create tables in:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export GAPWATCH_TEST_DSN="postgresql://postgres:postgres@localhost:5432/postgres"
|
|
17
|
+
pytest -q
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Before opening a pull request:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
ruff check .
|
|
24
|
+
ruff format .
|
|
25
|
+
pytest -q
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Adding a source
|
|
29
|
+
|
|
30
|
+
A source writes numbered changes and listens for them the way a real app would. Look at `src/gapwatch/listen_notify.py`: it returns what was sent, what was received, and when the listener was disconnected. `checker.py` does the rest. Please open an issue first so we can agree on the shape before you build it.
|
|
31
|
+
|
|
32
|
+
## Reporting results
|
|
33
|
+
|
|
34
|
+
If you run gapwatch against a service and get surprising numbers, open an issue with the command you ran, the JSON report and the versions involved. Please leave out connection strings and anything secret. I'll review it as soon as I can.
|
gapwatch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Akokodaripon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gapwatch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gapwatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Crash-test how database changes reach your app: find the changes it silently misses.
|
|
5
|
+
Project-URL: Repository, https://github.com/Dakokodaripon/gapwatch
|
|
6
|
+
Project-URL: Issues, https://github.com/Dakokodaripon/gapwatch/issues
|
|
7
|
+
Author: David Akokodaripon
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cdc,fault-injection,listen-notify,postgres,realtime,testing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# gapwatch
|
|
25
|
+
|
|
26
|
+
**Crash-test how database changes reach your app.**
|
|
27
|
+
|
|
28
|
+
Live dashboards, chat, order tracking and notifications all depend on database changes reaching the app. When a connection drops or a server restarts, some of those changes can disappear without any error. Nobody notices until a user says "my screen didn't update."
|
|
29
|
+
|
|
30
|
+
gapwatch measures it. It writes numbered changes to your database, listens the way your app does, cuts the connection on purpose, and tells you exactly what never arrived.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
$ gapwatch run --dsn postgresql://localhost/postgres
|
|
34
|
+
|
|
35
|
+
gapwatch 0.1.0 · source: postgres LISTEN/NOTIFY
|
|
36
|
+
|
|
37
|
+
Wrote 5,000 changes and cut the listener's connection 3 time(s) (run took 12.1s)
|
|
38
|
+
|
|
39
|
+
Delivered 3,492 (69.84%)
|
|
40
|
+
Missing 1,508
|
|
41
|
+
Duplicated 0
|
|
42
|
+
Out of order 0
|
|
43
|
+
|
|
44
|
+
Disconnect #1: 2.64s → 3.65s (1.01s offline), 502 changes missed
|
|
45
|
+
Disconnect #2: 4.53s → 5.53s (1.01s offline), 503 changes missed
|
|
46
|
+
Disconnect #3: 7.28s → 8.28s (1.01s offline), 503 changes missed
|
|
47
|
+
|
|
48
|
+
Missing changes: 1317–1818, 2259–2761, 3634–4136
|
|
49
|
+
|
|
50
|
+
Result: your app would have silently missed these changes.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That's a real run against Postgres 16, with a listener that waits one second before reconnecting, like many apps do.
|
|
54
|
+
|
|
55
|
+
**Reconnecting faster doesn't fix it.** With `--reconnect-delay 0`, the listener was back within about 10 milliseconds each time, and it still lost 3 changes per disconnect (9 of 5,000). Without any faults, all 5,000 arrived. The changes are lost in the gap itself, however short it is.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install gapwatch # or, until the first release: pip install git+https://github.com/Dakokodaripon/gapwatch
|
|
61
|
+
gapwatch run --dsn "postgresql://user:password@host:5432/dbname"
|
|
62
|
+
gapwatch cleanup --dsn "..."
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
You can also set `GAPWATCH_DSN` instead of passing `--dsn`.
|
|
66
|
+
|
|
67
|
+
**Use a test or staging database.** gapwatch creates a table called `gapwatch_events` and a trigger. `gapwatch cleanup` removes both.
|
|
68
|
+
|
|
69
|
+
## Running against Supabase
|
|
70
|
+
|
|
71
|
+
gapwatch works with a Supabase database, since it's Postgres underneath. This tests Postgres LISTEN/NOTIFY on your Supabase database. Support for Supabase Realtime itself is on the roadmap.
|
|
72
|
+
|
|
73
|
+
1. In your Supabase project, open **Connect** and copy the **Direct connection** string (or the **Session pooler** one if your network has no IPv6).
|
|
74
|
+
2. Run:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
gapwatch run --dsn "postgresql://postgres:YOUR_PASSWORD@db.YOUR_PROJECT_REF.supabase.co:5432/postgres"
|
|
78
|
+
gapwatch cleanup --dsn "..."
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Don't use the **Transaction pooler** (port 6543). It doesn't support LISTEN, so the listener can't subscribe.
|
|
82
|
+
|
|
83
|
+
Use a test or development project, not production.
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
|
|
87
|
+
| Option | Default | What it does |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `--count` | 5000 | How many changes to write |
|
|
90
|
+
| `--rate` | 500 | Changes per second (0 means as fast as possible) |
|
|
91
|
+
| `--faults` | 3 | How many times to cut the listener's connection |
|
|
92
|
+
| `--reconnect-delay` | 1.0 | Seconds the listener waits before reconnecting |
|
|
93
|
+
| `--drain` | 2.0 | Seconds to wait for late changes before counting |
|
|
94
|
+
| `--seed` | none | Makes the timing of faults repeatable |
|
|
95
|
+
| `--json PATH` | none | Also write the report as JSON |
|
|
96
|
+
| `--fail-on-loss` | off | Exit with code 1 if anything was lost, for use in CI |
|
|
97
|
+
|
|
98
|
+
## How it works
|
|
99
|
+
|
|
100
|
+
1. A trigger sends a notification for every row gapwatch inserts, each carrying a sequence number.
|
|
101
|
+
2. A listener subscribes the way a typical app does. If its connection drops, it waits, reconnects and subscribes again.
|
|
102
|
+
3. While changes are being written, gapwatch ends the listener's connection with `pg_terminate_backend`, the same thing that happens when a server restarts or a connection is killed.
|
|
103
|
+
4. The checker compares what was sent with what arrived, and works out which disconnect each missing change belongs to.
|
|
104
|
+
|
|
105
|
+
In systems engineering terms, it's fault-injection testing for realtime data delivery: it measures loss, duplication and ordering under degraded connectivity.
|
|
106
|
+
|
|
107
|
+
## Is this a Postgres bug?
|
|
108
|
+
|
|
109
|
+
No. Postgres documents that notifications are only delivered to sessions that are listening at the time. gapwatch makes that behaviour visible and measurable, so you can decide whether your app needs a way to catch up, such as re-reading recent rows after reconnecting.
|
|
110
|
+
|
|
111
|
+
## Roadmap
|
|
112
|
+
|
|
113
|
+
Version 0.1 tests Postgres `LISTEN/NOTIFY`. Next:
|
|
114
|
+
|
|
115
|
+
- Supabase Realtime
|
|
116
|
+
- Debezium and Kafka
|
|
117
|
+
- Logical replication consumers
|
|
118
|
+
- Network faults (latency, packet loss) as well as connection kills
|
|
119
|
+
- A catch-up mode, to measure whether a recovery strategy actually closes the gap
|
|
120
|
+
|
|
121
|
+
Ideas and results from your own setup are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
gapwatch-0.1.0/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# gapwatch
|
|
2
|
+
|
|
3
|
+
**Crash-test how database changes reach your app.**
|
|
4
|
+
|
|
5
|
+
Live dashboards, chat, order tracking and notifications all depend on database changes reaching the app. When a connection drops or a server restarts, some of those changes can disappear without any error. Nobody notices until a user says "my screen didn't update."
|
|
6
|
+
|
|
7
|
+
gapwatch measures it. It writes numbered changes to your database, listens the way your app does, cuts the connection on purpose, and tells you exactly what never arrived.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ gapwatch run --dsn postgresql://localhost/postgres
|
|
11
|
+
|
|
12
|
+
gapwatch 0.1.0 · source: postgres LISTEN/NOTIFY
|
|
13
|
+
|
|
14
|
+
Wrote 5,000 changes and cut the listener's connection 3 time(s) (run took 12.1s)
|
|
15
|
+
|
|
16
|
+
Delivered 3,492 (69.84%)
|
|
17
|
+
Missing 1,508
|
|
18
|
+
Duplicated 0
|
|
19
|
+
Out of order 0
|
|
20
|
+
|
|
21
|
+
Disconnect #1: 2.64s → 3.65s (1.01s offline), 502 changes missed
|
|
22
|
+
Disconnect #2: 4.53s → 5.53s (1.01s offline), 503 changes missed
|
|
23
|
+
Disconnect #3: 7.28s → 8.28s (1.01s offline), 503 changes missed
|
|
24
|
+
|
|
25
|
+
Missing changes: 1317–1818, 2259–2761, 3634–4136
|
|
26
|
+
|
|
27
|
+
Result: your app would have silently missed these changes.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That's a real run against Postgres 16, with a listener that waits one second before reconnecting, like many apps do.
|
|
31
|
+
|
|
32
|
+
**Reconnecting faster doesn't fix it.** With `--reconnect-delay 0`, the listener was back within about 10 milliseconds each time, and it still lost 3 changes per disconnect (9 of 5,000). Without any faults, all 5,000 arrived. The changes are lost in the gap itself, however short it is.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install gapwatch # or, until the first release: pip install git+https://github.com/Dakokodaripon/gapwatch
|
|
38
|
+
gapwatch run --dsn "postgresql://user:password@host:5432/dbname"
|
|
39
|
+
gapwatch cleanup --dsn "..."
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You can also set `GAPWATCH_DSN` instead of passing `--dsn`.
|
|
43
|
+
|
|
44
|
+
**Use a test or staging database.** gapwatch creates a table called `gapwatch_events` and a trigger. `gapwatch cleanup` removes both.
|
|
45
|
+
|
|
46
|
+
## Running against Supabase
|
|
47
|
+
|
|
48
|
+
gapwatch works with a Supabase database, since it's Postgres underneath. This tests Postgres LISTEN/NOTIFY on your Supabase database. Support for Supabase Realtime itself is on the roadmap.
|
|
49
|
+
|
|
50
|
+
1. In your Supabase project, open **Connect** and copy the **Direct connection** string (or the **Session pooler** one if your network has no IPv6).
|
|
51
|
+
2. Run:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
gapwatch run --dsn "postgresql://postgres:YOUR_PASSWORD@db.YOUR_PROJECT_REF.supabase.co:5432/postgres"
|
|
55
|
+
gapwatch cleanup --dsn "..."
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Don't use the **Transaction pooler** (port 6543). It doesn't support LISTEN, so the listener can't subscribe.
|
|
59
|
+
|
|
60
|
+
Use a test or development project, not production.
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
| Option | Default | What it does |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `--count` | 5000 | How many changes to write |
|
|
67
|
+
| `--rate` | 500 | Changes per second (0 means as fast as possible) |
|
|
68
|
+
| `--faults` | 3 | How many times to cut the listener's connection |
|
|
69
|
+
| `--reconnect-delay` | 1.0 | Seconds the listener waits before reconnecting |
|
|
70
|
+
| `--drain` | 2.0 | Seconds to wait for late changes before counting |
|
|
71
|
+
| `--seed` | none | Makes the timing of faults repeatable |
|
|
72
|
+
| `--json PATH` | none | Also write the report as JSON |
|
|
73
|
+
| `--fail-on-loss` | off | Exit with code 1 if anything was lost, for use in CI |
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
1. A trigger sends a notification for every row gapwatch inserts, each carrying a sequence number.
|
|
78
|
+
2. A listener subscribes the way a typical app does. If its connection drops, it waits, reconnects and subscribes again.
|
|
79
|
+
3. While changes are being written, gapwatch ends the listener's connection with `pg_terminate_backend`, the same thing that happens when a server restarts or a connection is killed.
|
|
80
|
+
4. The checker compares what was sent with what arrived, and works out which disconnect each missing change belongs to.
|
|
81
|
+
|
|
82
|
+
In systems engineering terms, it's fault-injection testing for realtime data delivery: it measures loss, duplication and ordering under degraded connectivity.
|
|
83
|
+
|
|
84
|
+
## Is this a Postgres bug?
|
|
85
|
+
|
|
86
|
+
No. Postgres documents that notifications are only delivered to sessions that are listening at the time. gapwatch makes that behaviour visible and measurable, so you can decide whether your app needs a way to catch up, such as re-reading recent rows after reconnecting.
|
|
87
|
+
|
|
88
|
+
## Roadmap
|
|
89
|
+
|
|
90
|
+
Version 0.1 tests Postgres `LISTEN/NOTIFY`. Next:
|
|
91
|
+
|
|
92
|
+
- Supabase Realtime
|
|
93
|
+
- Debezium and Kafka
|
|
94
|
+
- Logical replication consumers
|
|
95
|
+
- Network faults (latency, packet loss) as well as connection kills
|
|
96
|
+
- A catch-up mode, to measure whether a recovery strategy actually closes the gap
|
|
97
|
+
|
|
98
|
+
Ideas and results from your own setup are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gapwatch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Crash-test how database changes reach your app: find the changes it silently misses."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "David Akokodaripon" }]
|
|
14
|
+
keywords = ["postgres", "realtime", "cdc", "listen-notify", "fault-injection", "testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Database",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["psycopg[binary]>=3.2"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
gapwatch = "gapwatch.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/Dakokodaripon/gapwatch"
|
|
33
|
+
Issues = "https://github.com/Dakokodaripon/gapwatch/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/gapwatch"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint.isort]
|
|
49
|
+
known-first-party = ["gapwatch"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Compare what was written with what was received.
|
|
2
|
+
|
|
3
|
+
Everything in this module is pure: no database, no clock, no threads.
|
|
4
|
+
That keeps the part that decides "was anything lost?" easy to test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Window:
|
|
14
|
+
"""A period when the listener was disconnected, in seconds since the run started."""
|
|
15
|
+
|
|
16
|
+
start: float
|
|
17
|
+
end: float
|
|
18
|
+
|
|
19
|
+
def contains(self, t: float) -> bool:
|
|
20
|
+
return self.start <= t <= self.end
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Report:
|
|
25
|
+
sent: int
|
|
26
|
+
received: int
|
|
27
|
+
missing: list[int]
|
|
28
|
+
duplicates: dict[int, int]
|
|
29
|
+
out_of_order: int
|
|
30
|
+
missing_ranges: list[tuple[int, int]]
|
|
31
|
+
missing_by_window: list[int] = field(default_factory=list)
|
|
32
|
+
missing_outside_windows: int = 0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def delivered(self) -> int:
|
|
36
|
+
return self.sent - len(self.missing)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def delivery_rate(self) -> float:
|
|
40
|
+
return 1.0 if self.sent == 0 else self.delivered / self.sent
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
return not self.missing and not self.duplicates and self.out_of_order == 0
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"sent": self.sent,
|
|
49
|
+
"received": self.received,
|
|
50
|
+
"delivered": self.delivered,
|
|
51
|
+
"delivery_rate": round(self.delivery_rate, 6),
|
|
52
|
+
"missing": len(self.missing),
|
|
53
|
+
"missing_ranges": [list(r) for r in self.missing_ranges],
|
|
54
|
+
"duplicates": sum(c - 1 for c in self.duplicates.values()),
|
|
55
|
+
"out_of_order": self.out_of_order,
|
|
56
|
+
"missing_by_window": self.missing_by_window,
|
|
57
|
+
"missing_outside_windows": self.missing_outside_windows,
|
|
58
|
+
"ok": self.ok,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ranges(sorted_values: list[int]) -> list[tuple[int, int]]:
|
|
63
|
+
"""Collapse sorted integers into inclusive (first, last) runs."""
|
|
64
|
+
ranges: list[tuple[int, int]] = []
|
|
65
|
+
for value in sorted_values:
|
|
66
|
+
if ranges and value == ranges[-1][1] + 1:
|
|
67
|
+
ranges[-1] = (ranges[-1][0], value)
|
|
68
|
+
else:
|
|
69
|
+
ranges.append((value, value))
|
|
70
|
+
return ranges
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check(
|
|
74
|
+
sent: list[int],
|
|
75
|
+
received: list[int],
|
|
76
|
+
sent_at: dict[int, float] | None = None,
|
|
77
|
+
windows: list[Window] | None = None,
|
|
78
|
+
) -> Report:
|
|
79
|
+
"""Compare sent sequence numbers with the order they were received in.
|
|
80
|
+
|
|
81
|
+
- missing: sent but never received
|
|
82
|
+
- duplicates: received more than once (value -> times received)
|
|
83
|
+
- out_of_order: first deliveries that arrived after a higher number had
|
|
84
|
+
already been seen
|
|
85
|
+
- missing_by_window: if send times and disconnect windows are given, how
|
|
86
|
+
many missing changes were sent during each window
|
|
87
|
+
"""
|
|
88
|
+
sent_set = set(sent)
|
|
89
|
+
counts: dict[int, int] = {}
|
|
90
|
+
out_of_order = 0
|
|
91
|
+
highest = None
|
|
92
|
+
|
|
93
|
+
for seq in received:
|
|
94
|
+
if seq not in sent_set:
|
|
95
|
+
# Not ours (or from an older run); ignore rather than miscount.
|
|
96
|
+
continue
|
|
97
|
+
counts[seq] = counts.get(seq, 0) + 1
|
|
98
|
+
if counts[seq] > 1:
|
|
99
|
+
continue
|
|
100
|
+
if highest is not None and seq < highest:
|
|
101
|
+
out_of_order += 1
|
|
102
|
+
highest = seq if highest is None else max(highest, seq)
|
|
103
|
+
|
|
104
|
+
missing = sorted(sent_set - counts.keys())
|
|
105
|
+
duplicates = {seq: n for seq, n in counts.items() if n > 1}
|
|
106
|
+
|
|
107
|
+
report = Report(
|
|
108
|
+
sent=len(sent_set),
|
|
109
|
+
received=len(received),
|
|
110
|
+
missing=missing,
|
|
111
|
+
duplicates=duplicates,
|
|
112
|
+
out_of_order=out_of_order,
|
|
113
|
+
missing_ranges=_ranges(missing),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if windows:
|
|
117
|
+
by_window = [0] * len(windows)
|
|
118
|
+
outside = 0
|
|
119
|
+
for seq in missing:
|
|
120
|
+
t = (sent_at or {}).get(seq)
|
|
121
|
+
hit = (
|
|
122
|
+
None
|
|
123
|
+
if t is None
|
|
124
|
+
else next((i for i, w in enumerate(windows) if w.contains(t)), None)
|
|
125
|
+
)
|
|
126
|
+
if hit is None:
|
|
127
|
+
outside += 1
|
|
128
|
+
else:
|
|
129
|
+
by_window[hit] += 1
|
|
130
|
+
report.missing_by_window = by_window
|
|
131
|
+
report.missing_outside_windows = outside
|
|
132
|
+
|
|
133
|
+
return report
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Command line interface: `gapwatch run` and `gapwatch cleanup`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from gapwatch import __version__
|
|
11
|
+
from gapwatch.checker import Report, check
|
|
12
|
+
from gapwatch.listen_notify import RunConfig, RunResult, cleanup, run
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format(report: Report, result: RunResult, config: RunConfig) -> str:
|
|
16
|
+
lines = [
|
|
17
|
+
f"gapwatch {__version__} · source: postgres LISTEN/NOTIFY",
|
|
18
|
+
"",
|
|
19
|
+
f"Wrote {report.sent:,} changes and cut the listener's connection "
|
|
20
|
+
f"{len(result.windows)} time(s) (run took {result.duration:.1f}s)",
|
|
21
|
+
"",
|
|
22
|
+
f" Delivered {report.delivered:,} ({report.delivery_rate:.2%})",
|
|
23
|
+
f" Missing {len(report.missing):,}",
|
|
24
|
+
f" Duplicated {sum(c - 1 for c in report.duplicates.values()):,}",
|
|
25
|
+
f" Out of order {report.out_of_order:,}",
|
|
26
|
+
]
|
|
27
|
+
if result.windows:
|
|
28
|
+
lines.append("")
|
|
29
|
+
for i, (window, lost) in enumerate(
|
|
30
|
+
zip(result.windows, report.missing_by_window, strict=True), start=1
|
|
31
|
+
):
|
|
32
|
+
lines.append(
|
|
33
|
+
f" Disconnect #{i}: {window.start:5.2f}s → {window.end:5.2f}s "
|
|
34
|
+
f"({window.end - window.start:.2f}s offline), {lost:,} changes missed"
|
|
35
|
+
)
|
|
36
|
+
if report.missing_outside_windows:
|
|
37
|
+
lines.append(f" Missed outside any disconnect: {report.missing_outside_windows:,}")
|
|
38
|
+
if report.missing_ranges:
|
|
39
|
+
shown = ", ".join(f"{a}" if a == b else f"{a}–{b}" for a, b in report.missing_ranges[:5])
|
|
40
|
+
more = len(report.missing_ranges) - 5
|
|
41
|
+
lines.append("")
|
|
42
|
+
suffix = f" (+{more} more ranges)" if more > 0 else ""
|
|
43
|
+
lines.append(f" Missing changes: {shown}{suffix}")
|
|
44
|
+
lines.append("")
|
|
45
|
+
lines.append(
|
|
46
|
+
"Result: no changes lost."
|
|
47
|
+
if report.ok
|
|
48
|
+
else "Result: your app would have silently missed these changes."
|
|
49
|
+
)
|
|
50
|
+
return "\n".join(lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
54
|
+
config = RunConfig(
|
|
55
|
+
dsn=args.dsn,
|
|
56
|
+
count=args.count,
|
|
57
|
+
rate=args.rate,
|
|
58
|
+
faults=args.faults,
|
|
59
|
+
reconnect_delay=args.reconnect_delay,
|
|
60
|
+
drain=args.drain,
|
|
61
|
+
seed=args.seed,
|
|
62
|
+
)
|
|
63
|
+
result = run(config)
|
|
64
|
+
report = check(result.sent, result.received, result.sent_at, result.windows)
|
|
65
|
+
print(_format(report, result, config))
|
|
66
|
+
|
|
67
|
+
if args.json:
|
|
68
|
+
data = report.to_dict()
|
|
69
|
+
data.update(
|
|
70
|
+
run_id=result.run_id,
|
|
71
|
+
source="listen_notify",
|
|
72
|
+
duration_s=round(result.duration, 3),
|
|
73
|
+
disconnects=[
|
|
74
|
+
{"start_s": round(w.start, 3), "end_s": round(w.end, 3)} for w in result.windows
|
|
75
|
+
],
|
|
76
|
+
config={
|
|
77
|
+
"count": config.count,
|
|
78
|
+
"rate": config.rate,
|
|
79
|
+
"faults": config.faults,
|
|
80
|
+
"reconnect_delay": config.reconnect_delay,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
with open(args.json, "w", encoding="utf-8") as f:
|
|
84
|
+
json.dump(data, f, indent=2)
|
|
85
|
+
|
|
86
|
+
return 1 if args.fail_on_loss and not report.ok else 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cmd_cleanup(args: argparse.Namespace) -> int:
|
|
90
|
+
cleanup(args.dsn)
|
|
91
|
+
print("Removed gapwatch_events and gapwatch_notify().")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
prog="gapwatch",
|
|
98
|
+
description="Crash-test how database changes reach your app.",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument("--version", action="version", version=f"gapwatch {__version__}")
|
|
101
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
102
|
+
|
|
103
|
+
dsn_help = "Postgres connection string (or set GAPWATCH_DSN)"
|
|
104
|
+
default_dsn = os.environ.get("GAPWATCH_DSN")
|
|
105
|
+
|
|
106
|
+
p_run = sub.add_parser("run", help="write changes, cut connections, report what was lost")
|
|
107
|
+
p_run.add_argument("--dsn", default=default_dsn, required=default_dsn is None, help=dsn_help)
|
|
108
|
+
p_run.add_argument("--count", type=int, default=5000, help="changes to write (default 5000)")
|
|
109
|
+
p_run.add_argument("--rate", type=float, default=500, help="changes per second (default 500)")
|
|
110
|
+
p_run.add_argument(
|
|
111
|
+
"--faults", type=int, default=3, help="times to cut the connection (default 3)"
|
|
112
|
+
)
|
|
113
|
+
p_run.add_argument(
|
|
114
|
+
"--reconnect-delay",
|
|
115
|
+
type=float,
|
|
116
|
+
default=1.0,
|
|
117
|
+
help="seconds the listener waits before reconnecting (default 1.0)",
|
|
118
|
+
)
|
|
119
|
+
p_run.add_argument(
|
|
120
|
+
"--drain", type=float, default=2.0, help="seconds to wait for late changes (default 2)"
|
|
121
|
+
)
|
|
122
|
+
p_run.add_argument("--seed", type=int, default=None, help="seed for fault timing")
|
|
123
|
+
p_run.add_argument("--json", metavar="PATH", help="also write the report as JSON")
|
|
124
|
+
p_run.add_argument(
|
|
125
|
+
"--fail-on-loss", action="store_true", help="exit with code 1 if anything was lost"
|
|
126
|
+
)
|
|
127
|
+
p_run.set_defaults(func=_cmd_run)
|
|
128
|
+
|
|
129
|
+
p_clean = sub.add_parser("cleanup", help="remove the table and trigger gapwatch created")
|
|
130
|
+
p_clean.add_argument("--dsn", default=default_dsn, required=default_dsn is None, help=dsn_help)
|
|
131
|
+
p_clean.set_defaults(func=_cmd_cleanup)
|
|
132
|
+
|
|
133
|
+
return parser
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv: list[str] | None = None) -> int:
|
|
137
|
+
args = build_parser().parse_args(argv)
|
|
138
|
+
try:
|
|
139
|
+
return args.func(args)
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
return 130
|
|
142
|
+
except Exception as exc: # keep the CLI output readable
|
|
143
|
+
print(f"gapwatch: {exc}", file=sys.stderr)
|
|
144
|
+
return 2
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
sys.exit(main())
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Postgres LISTEN/NOTIFY source.
|
|
2
|
+
|
|
3
|
+
A trigger sends a notification for every row the writer inserts. A listener
|
|
4
|
+
receives them the way a typical app does: it reconnects after a failure and
|
|
5
|
+
subscribes again. Meanwhile the fault injector cuts the listener's connection
|
|
6
|
+
with pg_terminate_backend, so the run shows what a real app would miss.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
import psycopg
|
|
18
|
+
|
|
19
|
+
from gapwatch.checker import Window
|
|
20
|
+
|
|
21
|
+
CHANNEL = "gapwatch"
|
|
22
|
+
|
|
23
|
+
SETUP_SQL = f"""
|
|
24
|
+
create table if not exists gapwatch_events (
|
|
25
|
+
run_id text not null,
|
|
26
|
+
seq bigint not null,
|
|
27
|
+
created_at timestamptz not null default now(),
|
|
28
|
+
primary key (run_id, seq)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
create or replace function gapwatch_notify() returns trigger
|
|
32
|
+
language plpgsql as $$
|
|
33
|
+
begin
|
|
34
|
+
perform pg_notify('{CHANNEL}', new.run_id || ':' || new.seq);
|
|
35
|
+
return new;
|
|
36
|
+
end;
|
|
37
|
+
$$;
|
|
38
|
+
|
|
39
|
+
drop trigger if exists gapwatch_notify on gapwatch_events;
|
|
40
|
+
create trigger gapwatch_notify
|
|
41
|
+
after insert on gapwatch_events
|
|
42
|
+
for each row execute function gapwatch_notify();
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
CLEANUP_SQL = """
|
|
46
|
+
drop table if exists gapwatch_events;
|
|
47
|
+
drop function if exists gapwatch_notify();
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RunConfig:
|
|
53
|
+
dsn: str
|
|
54
|
+
count: int = 5000
|
|
55
|
+
rate: float = 500.0
|
|
56
|
+
faults: int = 3
|
|
57
|
+
reconnect_delay: float = 1.0
|
|
58
|
+
drain: float = 2.0
|
|
59
|
+
seed: int | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class RunResult:
|
|
64
|
+
run_id: str
|
|
65
|
+
sent: list[int] = field(default_factory=list)
|
|
66
|
+
sent_at: dict[int, float] = field(default_factory=dict)
|
|
67
|
+
received: list[int] = field(default_factory=list)
|
|
68
|
+
windows: list[Window] = field(default_factory=list)
|
|
69
|
+
duration: float = 0.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def setup(dsn: str) -> None:
|
|
73
|
+
with psycopg.connect(dsn, autocommit=True) as conn:
|
|
74
|
+
conn.execute(SETUP_SQL)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cleanup(dsn: str) -> None:
|
|
78
|
+
with psycopg.connect(dsn, autocommit=True) as conn:
|
|
79
|
+
conn.execute(CLEANUP_SQL)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Run:
|
|
83
|
+
def __init__(self, config: RunConfig):
|
|
84
|
+
self.config = config
|
|
85
|
+
self.result = RunResult(run_id=uuid.uuid4().hex[:12])
|
|
86
|
+
self.app_name = f"gapwatch-listener-{self.result.run_id}"
|
|
87
|
+
self.start = 0.0
|
|
88
|
+
self.listening = threading.Event()
|
|
89
|
+
self.writer_done = threading.Event()
|
|
90
|
+
self.stop = threading.Event()
|
|
91
|
+
self.kill_times: list[float] = []
|
|
92
|
+
self.resubscribe_times: list[float] = []
|
|
93
|
+
self.errors: list[BaseException] = []
|
|
94
|
+
|
|
95
|
+
def now(self) -> float:
|
|
96
|
+
return time.monotonic() - self.start
|
|
97
|
+
|
|
98
|
+
# The listener behaves like a typical app: subscribe, and if the
|
|
99
|
+
# connection drops, wait a moment, reconnect and subscribe again.
|
|
100
|
+
def listener(self) -> None:
|
|
101
|
+
prefix = self.result.run_id + ":"
|
|
102
|
+
try:
|
|
103
|
+
while not self.stop.is_set():
|
|
104
|
+
try:
|
|
105
|
+
with psycopg.connect(
|
|
106
|
+
self.config.dsn,
|
|
107
|
+
autocommit=True,
|
|
108
|
+
application_name=self.app_name,
|
|
109
|
+
) as conn:
|
|
110
|
+
conn.execute(f"listen {CHANNEL}")
|
|
111
|
+
if self.listening.is_set():
|
|
112
|
+
self.resubscribe_times.append(self.now())
|
|
113
|
+
self.listening.set()
|
|
114
|
+
while not self.stop.is_set():
|
|
115
|
+
for note in conn.notifies(timeout=0.2):
|
|
116
|
+
if note.payload.startswith(prefix):
|
|
117
|
+
self.result.received.append(int(note.payload[len(prefix) :]))
|
|
118
|
+
except psycopg.OperationalError:
|
|
119
|
+
if not self.stop.is_set():
|
|
120
|
+
time.sleep(self.config.reconnect_delay)
|
|
121
|
+
except BaseException as exc: # surface unexpected errors to the caller
|
|
122
|
+
self.errors.append(exc)
|
|
123
|
+
self.listening.set()
|
|
124
|
+
|
|
125
|
+
def writer(self) -> None:
|
|
126
|
+
interval = 1.0 / self.config.rate if self.config.rate > 0 else 0.0
|
|
127
|
+
try:
|
|
128
|
+
with psycopg.connect(self.config.dsn, autocommit=True) as conn:
|
|
129
|
+
next_at = time.monotonic()
|
|
130
|
+
for seq in range(1, self.config.count + 1):
|
|
131
|
+
conn.execute(
|
|
132
|
+
"insert into gapwatch_events (run_id, seq) values (%s, %s)",
|
|
133
|
+
(self.result.run_id, seq),
|
|
134
|
+
)
|
|
135
|
+
self.result.sent.append(seq)
|
|
136
|
+
self.result.sent_at[seq] = self.now()
|
|
137
|
+
next_at += interval
|
|
138
|
+
delay = next_at - time.monotonic()
|
|
139
|
+
if delay > 0:
|
|
140
|
+
time.sleep(delay)
|
|
141
|
+
except BaseException as exc:
|
|
142
|
+
self.errors.append(exc)
|
|
143
|
+
finally:
|
|
144
|
+
self.writer_done.set()
|
|
145
|
+
|
|
146
|
+
def fault_times(self) -> list[float]:
|
|
147
|
+
"""Spread the faults across the write period, with a little jitter."""
|
|
148
|
+
n = self.config.faults
|
|
149
|
+
if n <= 0:
|
|
150
|
+
return []
|
|
151
|
+
total = self.config.count / self.config.rate if self.config.rate > 0 else 1.0
|
|
152
|
+
rng = random.Random(self.config.seed)
|
|
153
|
+
slot = total / (n + 1)
|
|
154
|
+
return [slot * (i + 1) + rng.uniform(-0.2, 0.2) * slot for i in range(n)]
|
|
155
|
+
|
|
156
|
+
def injector(self) -> None:
|
|
157
|
+
try:
|
|
158
|
+
with psycopg.connect(self.config.dsn, autocommit=True) as conn:
|
|
159
|
+
for at in self.fault_times():
|
|
160
|
+
delay = at - self.now()
|
|
161
|
+
if delay > 0 and self.writer_done.wait(delay):
|
|
162
|
+
return
|
|
163
|
+
# Take the time before the cut, so changes committed while
|
|
164
|
+
# the terminate call is in flight count as part of it.
|
|
165
|
+
cut_at = self.now()
|
|
166
|
+
cur = conn.execute(
|
|
167
|
+
"select count(*) from ("
|
|
168
|
+
" select pg_terminate_backend(pid) from pg_stat_activity"
|
|
169
|
+
" where application_name = %s and pid <> pg_backend_pid()"
|
|
170
|
+
") t",
|
|
171
|
+
(self.app_name,),
|
|
172
|
+
)
|
|
173
|
+
row = cur.fetchone()
|
|
174
|
+
if row and row[0]:
|
|
175
|
+
self.kill_times.append(cut_at)
|
|
176
|
+
except BaseException as exc:
|
|
177
|
+
self.errors.append(exc)
|
|
178
|
+
|
|
179
|
+
def run(self) -> RunResult:
|
|
180
|
+
self.start = time.monotonic()
|
|
181
|
+
listener = threading.Thread(target=self.listener, daemon=True)
|
|
182
|
+
listener.start()
|
|
183
|
+
if not self.listening.wait(10):
|
|
184
|
+
self.stop.set()
|
|
185
|
+
raise RuntimeError("listener could not subscribe within 10 seconds")
|
|
186
|
+
if self.errors:
|
|
187
|
+
raise self.errors[0]
|
|
188
|
+
|
|
189
|
+
writer = threading.Thread(target=self.writer, daemon=True)
|
|
190
|
+
injector = threading.Thread(target=self.injector, daemon=True)
|
|
191
|
+
writer.start()
|
|
192
|
+
injector.start()
|
|
193
|
+
writer.join()
|
|
194
|
+
injector.join()
|
|
195
|
+
|
|
196
|
+
# Give in-flight notifications time to arrive before counting.
|
|
197
|
+
time.sleep(self.config.drain)
|
|
198
|
+
self.stop.set()
|
|
199
|
+
listener.join(timeout=5)
|
|
200
|
+
|
|
201
|
+
if self.errors:
|
|
202
|
+
raise self.errors[0]
|
|
203
|
+
|
|
204
|
+
end = self.now()
|
|
205
|
+
for killed in self.kill_times:
|
|
206
|
+
back = next((t for t in self.resubscribe_times if t >= killed), end)
|
|
207
|
+
self.result.windows.append(Window(start=killed, end=back))
|
|
208
|
+
# Each resubscribe belongs to one fault.
|
|
209
|
+
if back in self.resubscribe_times:
|
|
210
|
+
self.resubscribe_times.remove(back)
|
|
211
|
+
self.result.duration = end
|
|
212
|
+
return self.result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run(config: RunConfig) -> RunResult:
|
|
216
|
+
setup(config.dsn)
|
|
217
|
+
return _Run(config).run()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from gapwatch.checker import Window, check
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_everything_delivered_in_order():
|
|
5
|
+
report = check([1, 2, 3], [1, 2, 3])
|
|
6
|
+
assert report.ok
|
|
7
|
+
assert report.delivery_rate == 1.0
|
|
8
|
+
assert report.missing == []
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_missing_changes_are_grouped_into_ranges():
|
|
12
|
+
report = check(list(range(1, 11)), [1, 2, 6, 7, 10])
|
|
13
|
+
assert report.missing == [3, 4, 5, 8, 9]
|
|
14
|
+
assert report.missing_ranges == [(3, 5), (8, 9)]
|
|
15
|
+
assert report.delivered == 5
|
|
16
|
+
assert not report.ok
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_duplicates_are_counted_once_per_extra_copy():
|
|
20
|
+
report = check([1, 2, 3], [1, 2, 2, 2, 3])
|
|
21
|
+
assert report.duplicates == {2: 3}
|
|
22
|
+
assert report.to_dict()["duplicates"] == 2
|
|
23
|
+
assert report.missing == []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_duplicates_do_not_count_as_out_of_order():
|
|
27
|
+
report = check([1, 2, 3], [1, 2, 3, 2])
|
|
28
|
+
assert report.out_of_order == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_out_of_order_counts_late_first_deliveries():
|
|
32
|
+
report = check([1, 2, 3, 4], [1, 3, 2, 4])
|
|
33
|
+
assert report.out_of_order == 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_unknown_values_are_ignored():
|
|
37
|
+
report = check([1, 2], [1, 99, 2])
|
|
38
|
+
assert report.ok
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_empty_run_is_ok():
|
|
42
|
+
report = check([], [])
|
|
43
|
+
assert report.ok
|
|
44
|
+
assert report.delivery_rate == 1.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_missing_changes_are_attributed_to_disconnect_windows():
|
|
48
|
+
sent = [1, 2, 3, 4, 5, 6]
|
|
49
|
+
sent_at = {1: 0.1, 2: 1.1, 3: 1.2, 4: 2.0, 5: 3.1, 6: 4.0}
|
|
50
|
+
windows = [Window(1.0, 1.5), Window(3.0, 3.5)]
|
|
51
|
+
report = check(sent, [1, 4, 6], sent_at, windows)
|
|
52
|
+
assert report.missing_by_window == [2, 1]
|
|
53
|
+
assert report.missing_outside_windows == 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_missing_outside_windows_is_reported_separately():
|
|
57
|
+
windows = [Window(1.0, 1.5)]
|
|
58
|
+
report = check([1, 2], [], {1: 1.2, 2: 9.0}, windows)
|
|
59
|
+
assert report.missing_by_window == [1]
|
|
60
|
+
assert report.missing_outside_windows == 1
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Runs against a real Postgres. Skipped unless GAPWATCH_TEST_DSN is set."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from gapwatch.checker import check
|
|
8
|
+
from gapwatch.cli import main
|
|
9
|
+
from gapwatch.listen_notify import RunConfig, cleanup, run
|
|
10
|
+
|
|
11
|
+
DSN = os.environ.get("GAPWATCH_TEST_DSN")
|
|
12
|
+
pytestmark = pytest.mark.skipif(not DSN, reason="set GAPWATCH_TEST_DSN to run")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def _clean():
|
|
17
|
+
yield
|
|
18
|
+
cleanup(DSN)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_no_faults_means_nothing_lost():
|
|
22
|
+
result = run(RunConfig(dsn=DSN, count=300, rate=0, faults=0, drain=1.0))
|
|
23
|
+
report = check(result.sent, result.received)
|
|
24
|
+
assert report.ok, report.to_dict()
|
|
25
|
+
assert result.windows == []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cutting_the_connection_loses_changes_inside_the_window():
|
|
29
|
+
result = run(RunConfig(dsn=DSN, count=1000, rate=200, faults=2, reconnect_delay=0.5, seed=1))
|
|
30
|
+
report = check(result.sent, result.received, result.sent_at, result.windows)
|
|
31
|
+
assert len(result.windows) == 2
|
|
32
|
+
assert report.missing, "expected LISTEN/NOTIFY to miss changes while disconnected"
|
|
33
|
+
# Nearly every lost change should fall inside a disconnect window.
|
|
34
|
+
assert sum(report.missing_by_window) >= len(report.missing) * 0.9
|
|
35
|
+
assert report.duplicates == {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_cli_exit_code_reflects_loss(tmp_path):
|
|
39
|
+
out = tmp_path / "report.json"
|
|
40
|
+
code = main(
|
|
41
|
+
[
|
|
42
|
+
"run",
|
|
43
|
+
"--dsn",
|
|
44
|
+
DSN,
|
|
45
|
+
"--count",
|
|
46
|
+
"400",
|
|
47
|
+
"--rate",
|
|
48
|
+
"200",
|
|
49
|
+
"--faults",
|
|
50
|
+
"1",
|
|
51
|
+
"--reconnect-delay",
|
|
52
|
+
"0.5",
|
|
53
|
+
"--json",
|
|
54
|
+
str(out),
|
|
55
|
+
"--fail-on-loss",
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
assert code == 1
|
|
59
|
+
assert out.exists()
|