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.
@@ -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,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ *.json
11
+ !package.json
@@ -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.
@@ -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
@@ -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,3 @@
1
+ """gapwatch: crash-test how database changes reach your app."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from gapwatch.cli import main
4
+
5
+ sys.exit(main())
@@ -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()