phlo-postgres 0.14.0__tar.gz → 0.15.2__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.
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/PKG-INFO +2 -2
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/pyproject.toml +2 -2
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/__init__.py +7 -1
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/authorization.py +6 -1
- phlo_postgres-0.15.2/src/phlo_postgres/checkpoints.py +309 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/cli.py +19 -101
- phlo_postgres-0.15.2/src/phlo_postgres/cli_plugin.py +24 -0
- phlo_postgres-0.15.2/src/phlo_postgres/continuity.py +168 -0
- phlo_postgres-0.15.2/src/phlo_postgres/dataset_state_store.py +426 -0
- phlo_postgres-0.15.2/src/phlo_postgres/plugin.py +231 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/publish_target.py +3 -17
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/resource.py +20 -74
- phlo_postgres-0.15.2/src/phlo_postgres/security_readiness.py +46 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/settings.py +33 -48
- phlo_postgres-0.15.2/src/phlo_postgres/volume_setup.yaml +13 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/PKG-INFO +2 -2
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/SOURCES.txt +7 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/requires.txt +1 -1
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_authorization.py +21 -77
- phlo_postgres-0.15.2/tests/test_checkpoints.py +221 -0
- phlo_postgres-0.15.2/tests/test_continuity.py +137 -0
- phlo_postgres-0.15.2/tests/test_dataset_state_store.py +348 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_postgres_cli.py +6 -1
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_postgres_plugin.py +53 -6
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_resource.py +4 -6
- phlo_postgres-0.14.0/src/phlo_postgres/cli_plugin.py +0 -17
- phlo_postgres-0.14.0/src/phlo_postgres/plugin.py +0 -141
- phlo_postgres-0.14.0/src/phlo_postgres/volume_setup.yaml +0 -24
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/README.md +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/setup.cfg +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/exporter_service.yaml +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/service.yaml +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/settings_store.py +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/dependency_links.txt +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/entry_points.txt +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/top_level.txt +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_integration_postgres.py +0 -0
- {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_settings_store.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phlo-postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.2
|
|
4
4
|
Summary: Postgres service plugin for Phlo
|
|
5
5
|
Author-email: Phlo Team <team@phlo.dev>
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
8
|
Description-Content-Type: text/plain
|
|
9
|
-
Requires-Dist: phlo<0.
|
|
9
|
+
Requires-Dist: phlo<0.17,>=0.16.0
|
|
10
10
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -7,13 +7,13 @@ requires = [
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
dependencies = [
|
|
10
|
-
"phlo>=0.
|
|
10
|
+
"phlo>=0.16.0,<0.17",
|
|
11
11
|
"psycopg2-binary>=2.9.11",
|
|
12
12
|
]
|
|
13
13
|
description = "Postgres service plugin for Phlo"
|
|
14
14
|
name = "phlo-postgres"
|
|
15
15
|
requires-python = ">=3.11"
|
|
16
|
-
version = "0.
|
|
16
|
+
version = "0.15.2"
|
|
17
17
|
|
|
18
18
|
[[project.authors]]
|
|
19
19
|
email = "team@phlo.dev"
|
|
@@ -15,6 +15,9 @@ Example:
|
|
|
15
15
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
from importlib.metadata import version
|
|
19
|
+
|
|
20
|
+
from phlo_postgres.checkpoints import PostgresIngestionCheckpointStore
|
|
18
21
|
from phlo_postgres.plugin import PostgresServicePlugin
|
|
19
22
|
from phlo_postgres.publish_target import PostgresPublishTarget
|
|
20
23
|
from phlo_postgres.resource import PostgresResource
|
|
@@ -22,6 +25,7 @@ from phlo_postgres.settings import PostgresSettings, get_settings
|
|
|
22
25
|
from phlo_postgres.settings_store import PostgresSettingsStore
|
|
23
26
|
|
|
24
27
|
__all__ = [
|
|
28
|
+
"PostgresIngestionCheckpointStore",
|
|
25
29
|
"PostgresPublishTarget",
|
|
26
30
|
"PostgresResource",
|
|
27
31
|
"PostgresServicePlugin",
|
|
@@ -29,4 +33,6 @@ __all__ = [
|
|
|
29
33
|
"PostgresSettingsStore",
|
|
30
34
|
"get_settings",
|
|
31
35
|
]
|
|
32
|
-
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__version__ = version("phlo-postgres")
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Authorization surface table for the phlo-postgres CLI.
|
|
2
|
+
|
|
3
|
+
Declares which postgres commands mutate state plus their dataset resources and
|
|
4
|
+
required actions; the shared CLI surface adapter enforces these mappings.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
@@ -38,4 +42,5 @@ PostgresCliSurfaceAdapter = cli_surface_adapter_class(
|
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def get_postgres_cli_adapter() -> CliSurfaceAdapter:
|
|
45
|
+
"""Return the shared Postgres CLI surface adapter instance."""
|
|
41
46
|
return PostgresCliSurfaceAdapter.get_instance()
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Durable ingestion checkpoints backed by Phlo PostgreSQL.
|
|
2
|
+
|
|
3
|
+
Implements the neutral :class:`~phlo.capabilities.interfaces.IngestionCheckpointStore`
|
|
4
|
+
contract on the platform's existing PostgreSQL service so stream consumers
|
|
5
|
+
(Kafka today) can persist claimed offset ranges, bind them to output Iceberg
|
|
6
|
+
snapshots, and commit only after the snapshot is audited and promoted.
|
|
7
|
+
|
|
8
|
+
Claims are serialized per idempotency key with a PostgreSQL advisory
|
|
9
|
+
transaction lock plus a unique partial index, so two workers racing on the
|
|
10
|
+
same range cannot both hold an open claim.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from phlo.capabilities.interfaces import (
|
|
19
|
+
CheckpointRecord,
|
|
20
|
+
IngestionCheckpointStore,
|
|
21
|
+
SourceOffsetRange,
|
|
22
|
+
)
|
|
23
|
+
from phlo.logging import get_logger
|
|
24
|
+
from phlo_postgres.resource import PostgresResource
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
_SCHEMA_NAME = "phlo"
|
|
29
|
+
_TABLE_NAME = "ingestion_checkpoints"
|
|
30
|
+
_OPEN_STATUSES = ("claimed", "staged", "failed")
|
|
31
|
+
_RESTAGABLE_STATUSES = ("claimed", "staged", "failed")
|
|
32
|
+
|
|
33
|
+
_DDL_STATEMENTS = (
|
|
34
|
+
f"CREATE SCHEMA IF NOT EXISTS {_SCHEMA_NAME}",
|
|
35
|
+
f"""
|
|
36
|
+
CREATE TABLE IF NOT EXISTS {_SCHEMA_NAME}.{_TABLE_NAME} (
|
|
37
|
+
checkpoint_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
38
|
+
source_id TEXT NOT NULL,
|
|
39
|
+
target_table TEXT NOT NULL,
|
|
40
|
+
status TEXT NOT NULL,
|
|
41
|
+
ranges JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
42
|
+
snapshot_id BIGINT,
|
|
43
|
+
release_id TEXT,
|
|
44
|
+
idempotency_key TEXT,
|
|
45
|
+
failure_reason TEXT,
|
|
46
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
47
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
48
|
+
)
|
|
49
|
+
""",
|
|
50
|
+
f"""
|
|
51
|
+
CREATE UNIQUE INDEX IF NOT EXISTS {_TABLE_NAME}_idempotency_key_idx
|
|
52
|
+
ON {_SCHEMA_NAME}.{_TABLE_NAME} (idempotency_key)
|
|
53
|
+
WHERE idempotency_key IS NOT NULL
|
|
54
|
+
""",
|
|
55
|
+
f"""
|
|
56
|
+
CREATE INDEX IF NOT EXISTS {_TABLE_NAME}_source_status_idx
|
|
57
|
+
ON {_SCHEMA_NAME}.{_TABLE_NAME} (source_id, status)
|
|
58
|
+
""",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_SELECT_COLUMNS = (
|
|
62
|
+
"checkpoint_id, source_id, target_table, status, ranges, snapshot_id, "
|
|
63
|
+
"release_id, idempotency_key, failure_reason, updated_at"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ranges_to_json(ranges: list[SourceOffsetRange]) -> list[dict[str, Any]]:
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
"topic": item.topic,
|
|
71
|
+
"partition": item.partition,
|
|
72
|
+
"start_offset": item.start_offset,
|
|
73
|
+
"end_offset": item.end_offset,
|
|
74
|
+
}
|
|
75
|
+
for item in ranges
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ranges_from_json(raw: Any) -> tuple[SourceOffsetRange, ...]:
|
|
80
|
+
if not raw:
|
|
81
|
+
return ()
|
|
82
|
+
return tuple(
|
|
83
|
+
SourceOffsetRange(
|
|
84
|
+
topic=item["topic"],
|
|
85
|
+
partition=int(item["partition"]),
|
|
86
|
+
start_offset=int(item["start_offset"]),
|
|
87
|
+
end_offset=int(item["end_offset"]),
|
|
88
|
+
)
|
|
89
|
+
for item in raw
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _record_from_row(row: tuple) -> CheckpointRecord:
|
|
94
|
+
(
|
|
95
|
+
checkpoint_id,
|
|
96
|
+
source_id,
|
|
97
|
+
target_table,
|
|
98
|
+
status,
|
|
99
|
+
ranges,
|
|
100
|
+
snapshot_id,
|
|
101
|
+
release_id,
|
|
102
|
+
idempotency_key,
|
|
103
|
+
failure_reason,
|
|
104
|
+
updated_at,
|
|
105
|
+
) = row
|
|
106
|
+
return CheckpointRecord(
|
|
107
|
+
checkpoint_id=str(checkpoint_id),
|
|
108
|
+
source_id=source_id,
|
|
109
|
+
target_table=target_table,
|
|
110
|
+
status=status,
|
|
111
|
+
ranges=_ranges_from_json(ranges),
|
|
112
|
+
snapshot_id=snapshot_id,
|
|
113
|
+
release_id=release_id,
|
|
114
|
+
idempotency_key=idempotency_key,
|
|
115
|
+
failure_reason=failure_reason,
|
|
116
|
+
updated_at=updated_at,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PostgresIngestionCheckpointStore(IngestionCheckpointStore):
|
|
121
|
+
"""PostgreSQL-backed durable checkpoint store for stream ingestion."""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
resource: PostgresResource | None = None,
|
|
127
|
+
table_ensured: bool = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
self._resource = resource
|
|
130
|
+
self._table_ensured = table_ensured
|
|
131
|
+
|
|
132
|
+
def _ensure_resource(self) -> PostgresResource:
|
|
133
|
+
if self._resource is None:
|
|
134
|
+
self._resource = PostgresResource()
|
|
135
|
+
return self._resource
|
|
136
|
+
|
|
137
|
+
def _ensure_table(self) -> None:
|
|
138
|
+
if self._table_ensured:
|
|
139
|
+
return
|
|
140
|
+
resource = self._ensure_resource()
|
|
141
|
+
with resource.transactional_cursor() as cursor:
|
|
142
|
+
for statement in _DDL_STATEMENTS:
|
|
143
|
+
cursor.execute(statement)
|
|
144
|
+
self._table_ensured = True
|
|
145
|
+
|
|
146
|
+
def claim(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
source_id: str,
|
|
150
|
+
target_table: str,
|
|
151
|
+
ranges: list[SourceOffsetRange],
|
|
152
|
+
idempotency_key: str | None = None,
|
|
153
|
+
) -> CheckpointRecord:
|
|
154
|
+
"""Record an exclusive claim on the supplied ranges.
|
|
155
|
+
|
|
156
|
+
Existing claims with the same idempotency key are returned unchanged
|
|
157
|
+
so a retry after a crash resumes the same checkpoint instead of
|
|
158
|
+
double-claiming the range.
|
|
159
|
+
"""
|
|
160
|
+
self._ensure_table()
|
|
161
|
+
resource = self._ensure_resource()
|
|
162
|
+
lock_key = idempotency_key or f"{source_id}:{target_table}"
|
|
163
|
+
with resource.transactional_cursor() as cursor:
|
|
164
|
+
# The advisory lock serialises concurrent claims for the same key
|
|
165
|
+
# before the existence check, closing the check-then-insert race.
|
|
166
|
+
cursor.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (lock_key,))
|
|
167
|
+
if idempotency_key:
|
|
168
|
+
cursor.execute(
|
|
169
|
+
f"SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME} "
|
|
170
|
+
"WHERE idempotency_key = %s",
|
|
171
|
+
(idempotency_key,),
|
|
172
|
+
)
|
|
173
|
+
row = cursor.fetchone()
|
|
174
|
+
if row:
|
|
175
|
+
return _record_from_row(row)
|
|
176
|
+
cursor.execute(
|
|
177
|
+
f"""
|
|
178
|
+
INSERT INTO {_SCHEMA_NAME}.{_TABLE_NAME}
|
|
179
|
+
(source_id, target_table, status, ranges, idempotency_key)
|
|
180
|
+
VALUES (%s, %s, 'claimed', %s, %s)
|
|
181
|
+
RETURNING {_SELECT_COLUMNS}
|
|
182
|
+
""",
|
|
183
|
+
(
|
|
184
|
+
source_id,
|
|
185
|
+
target_table,
|
|
186
|
+
json.dumps(_ranges_to_json(ranges)),
|
|
187
|
+
idempotency_key,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
return _record_from_row(cursor.fetchone())
|
|
191
|
+
|
|
192
|
+
def record_snapshot(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
checkpoint_id: str,
|
|
196
|
+
snapshot_id: int | str,
|
|
197
|
+
release_id: str | None = None,
|
|
198
|
+
) -> CheckpointRecord:
|
|
199
|
+
"""Bind the claimed ranges to the output snapshot that represents them.
|
|
200
|
+
|
|
201
|
+
A ``failed`` checkpoint may re-stage once its blocking issue (for
|
|
202
|
+
example a schema migration) is resolved; the failure reason is
|
|
203
|
+
cleared on the transition.
|
|
204
|
+
"""
|
|
205
|
+
self._ensure_table()
|
|
206
|
+
resource = self._ensure_resource()
|
|
207
|
+
with resource.transactional_cursor() as cursor:
|
|
208
|
+
cursor.execute(
|
|
209
|
+
f"""
|
|
210
|
+
UPDATE {_SCHEMA_NAME}.{_TABLE_NAME}
|
|
211
|
+
SET status = 'staged', snapshot_id = %s, release_id = %s,
|
|
212
|
+
failure_reason = NULL, updated_at = NOW()
|
|
213
|
+
WHERE checkpoint_id = %s AND status IN %s
|
|
214
|
+
RETURNING {_SELECT_COLUMNS}
|
|
215
|
+
""",
|
|
216
|
+
(int(snapshot_id), release_id, checkpoint_id, _RESTAGABLE_STATUSES),
|
|
217
|
+
)
|
|
218
|
+
row = cursor.fetchone()
|
|
219
|
+
if row is None:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Checkpoint {checkpoint_id!r} is not open; refusing to bind a snapshot."
|
|
222
|
+
)
|
|
223
|
+
return _record_from_row(row)
|
|
224
|
+
|
|
225
|
+
def commit(self, *, checkpoint_id: str) -> CheckpointRecord:
|
|
226
|
+
"""Mark a snapshot-bound checkpoint as durably committed."""
|
|
227
|
+
self._ensure_table()
|
|
228
|
+
resource = self._ensure_resource()
|
|
229
|
+
with resource.transactional_cursor() as cursor:
|
|
230
|
+
cursor.execute(
|
|
231
|
+
f"""
|
|
232
|
+
UPDATE {_SCHEMA_NAME}.{_TABLE_NAME}
|
|
233
|
+
SET status = 'committed', updated_at = NOW()
|
|
234
|
+
WHERE checkpoint_id = %s AND status = 'staged' AND snapshot_id IS NOT NULL
|
|
235
|
+
RETURNING {_SELECT_COLUMNS}
|
|
236
|
+
""",
|
|
237
|
+
(checkpoint_id,),
|
|
238
|
+
)
|
|
239
|
+
row = cursor.fetchone()
|
|
240
|
+
if row is None:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"Checkpoint {checkpoint_id!r} has no audited snapshot; refusing to commit."
|
|
243
|
+
)
|
|
244
|
+
return _record_from_row(row)
|
|
245
|
+
|
|
246
|
+
def fail(self, *, checkpoint_id: str, reason: str) -> CheckpointRecord:
|
|
247
|
+
"""Mark a checkpoint failed while retaining its claimed ranges."""
|
|
248
|
+
self._ensure_table()
|
|
249
|
+
resource = self._ensure_resource()
|
|
250
|
+
with resource.transactional_cursor() as cursor:
|
|
251
|
+
cursor.execute(
|
|
252
|
+
f"""
|
|
253
|
+
UPDATE {_SCHEMA_NAME}.{_TABLE_NAME}
|
|
254
|
+
SET status = 'failed', failure_reason = %s, updated_at = NOW()
|
|
255
|
+
WHERE checkpoint_id = %s AND status IN %s
|
|
256
|
+
RETURNING {_SELECT_COLUMNS}
|
|
257
|
+
""",
|
|
258
|
+
(reason, checkpoint_id, _OPEN_STATUSES),
|
|
259
|
+
)
|
|
260
|
+
row = cursor.fetchone()
|
|
261
|
+
if row is None:
|
|
262
|
+
raise ValueError(f"Checkpoint {checkpoint_id!r} is not open; cannot mark failed.")
|
|
263
|
+
return _record_from_row(row)
|
|
264
|
+
|
|
265
|
+
def latest_committed(self, *, source_id: str, target_table: str) -> CheckpointRecord | None:
|
|
266
|
+
"""Return the newest committed checkpoint for one source/table pair."""
|
|
267
|
+
self._ensure_table()
|
|
268
|
+
resource = self._ensure_resource()
|
|
269
|
+
with resource.cursor() as cursor:
|
|
270
|
+
cursor.execute(
|
|
271
|
+
f"""
|
|
272
|
+
SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME}
|
|
273
|
+
WHERE source_id = %s AND target_table = %s AND status = 'committed'
|
|
274
|
+
ORDER BY updated_at DESC
|
|
275
|
+
LIMIT 1
|
|
276
|
+
""",
|
|
277
|
+
(source_id, target_table),
|
|
278
|
+
)
|
|
279
|
+
row = cursor.fetchone()
|
|
280
|
+
return _record_from_row(row) if row else None
|
|
281
|
+
|
|
282
|
+
def find_by_idempotency_key(self, *, idempotency_key: str) -> CheckpointRecord | None:
|
|
283
|
+
"""Resolve an existing claim by its deterministic idempotency key."""
|
|
284
|
+
self._ensure_table()
|
|
285
|
+
resource = self._ensure_resource()
|
|
286
|
+
with resource.cursor() as cursor:
|
|
287
|
+
cursor.execute(
|
|
288
|
+
f"SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME} "
|
|
289
|
+
"WHERE idempotency_key = %s",
|
|
290
|
+
(idempotency_key,),
|
|
291
|
+
)
|
|
292
|
+
row = cursor.fetchone()
|
|
293
|
+
return _record_from_row(row) if row else None
|
|
294
|
+
|
|
295
|
+
def list_open(self, *, source_id: str) -> list[CheckpointRecord]:
|
|
296
|
+
"""List claimed-but-uncommitted checkpoints needing reconciliation."""
|
|
297
|
+
self._ensure_table()
|
|
298
|
+
resource = self._ensure_resource()
|
|
299
|
+
with resource.cursor() as cursor:
|
|
300
|
+
cursor.execute(
|
|
301
|
+
f"""
|
|
302
|
+
SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME}
|
|
303
|
+
WHERE source_id = %s AND status IN %s
|
|
304
|
+
ORDER BY updated_at ASC
|
|
305
|
+
""",
|
|
306
|
+
(source_id, _OPEN_STATUSES),
|
|
307
|
+
)
|
|
308
|
+
rows = cursor.fetchall()
|
|
309
|
+
return [_record_from_row(row) for row in rows]
|
|
@@ -43,26 +43,10 @@ from phlo_postgres.settings import get_settings
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _read_sql(*, query: str | None, file: Path | None) -> str:
|
|
46
|
-
"""Read SQL from inline query string or file path.
|
|
47
|
-
|
|
48
|
-
Validates that exactly one of query or file is provided and returns the
|
|
49
|
-
SQL content. Handles empty file detection and encoding issues.
|
|
50
|
-
|
|
51
|
-
Args:
|
|
52
|
-
query: Inline SQL query string.
|
|
53
|
-
file: Path to SQL file to read.
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
str: The SQL content to execute.
|
|
57
|
-
|
|
58
|
-
Raises:
|
|
59
|
-
click.ClickException: If both query and file are provided, neither is
|
|
60
|
-
provided, or the file is empty/cannot be read.
|
|
61
|
-
|
|
62
|
-
Example:
|
|
63
|
-
>>> sql = _read_sql(query="SELECT 1")
|
|
64
|
-
>>> sql = _read_sql(file=Path("query.sql"))
|
|
46
|
+
"""Read SQL from an inline query string or file path.
|
|
65
47
|
|
|
48
|
+
Exactly one of ``query`` and ``file`` must be provided. Raise ClickException
|
|
49
|
+
when both or neither are given, or the file is empty or unreadable.
|
|
66
50
|
"""
|
|
67
51
|
if query and file:
|
|
68
52
|
raise exclusive_options_error("an inline query", "--file")
|
|
@@ -85,22 +69,14 @@ def _require_container_backend() -> None:
|
|
|
85
69
|
|
|
86
70
|
|
|
87
71
|
def _postgres_exec_base(*, tty: bool) -> list[str]:
|
|
88
|
-
"""Build the docker compose exec base command for PostgreSQL container.
|
|
89
|
-
|
|
90
|
-
Constructs the initial portion of the docker compose exec command including
|
|
91
|
-
project name and service name. Used as a base for all container operations.
|
|
72
|
+
"""Build the docker compose exec base command for the PostgreSQL container.
|
|
92
73
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
commands that capture output.
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
list[str]: Base command as a list of strings ready for subprocess.
|
|
74
|
+
Includes project and service names; ``tty=False`` drops the ``-t`` flag for
|
|
75
|
+
commands that capture output.
|
|
99
76
|
|
|
100
77
|
Example:
|
|
101
78
|
>>> cmd = _postgres_exec_base(tty=True)
|
|
102
79
|
>>> # Returns: ['docker', 'compose', '-p', 'phlo', '-f', '...', 'exec', '-t', 'postgres']
|
|
103
|
-
|
|
104
80
|
"""
|
|
105
81
|
phlo_dir = ensure_compose_project()
|
|
106
82
|
project_name = get_project_name()
|
|
@@ -113,25 +89,13 @@ def _postgres_exec_base(*, tty: bool) -> list[str]:
|
|
|
113
89
|
|
|
114
90
|
|
|
115
91
|
def _postgres_identity(*, user: str | None, database: str | None) -> tuple[str, str]:
|
|
116
|
-
"""Resolve PostgreSQL
|
|
117
|
-
|
|
118
|
-
Returns explicit values if provided, otherwise falls back to settings
|
|
119
|
-
defaults. This allows CLI commands to use configured defaults while
|
|
120
|
-
permitting overrides.
|
|
92
|
+
"""Resolve PostgreSQL user and database, falling back to settings defaults.
|
|
121
93
|
|
|
122
|
-
|
|
123
|
-
user: Database username override, or None to use settings default.
|
|
124
|
-
database: Database name override, or None to use settings default.
|
|
125
|
-
|
|
126
|
-
Returns:
|
|
127
|
-
tuple[str, str]: Tuple of (resolved_user, resolved_database).
|
|
94
|
+
Returns a ``(resolved_user, resolved_database)`` tuple.
|
|
128
95
|
|
|
129
96
|
Example:
|
|
130
|
-
>>> user, db = _postgres_identity(user=None, database=None)
|
|
131
|
-
>>> # Uses settings.postgres_user and settings.postgres_db
|
|
132
97
|
>>> user, db = _postgres_identity(user="admin", database=None)
|
|
133
98
|
>>> # Uses "admin" for user, settings default for database
|
|
134
|
-
|
|
135
99
|
"""
|
|
136
100
|
settings = get_settings()
|
|
137
101
|
return user or settings.postgres_user, database or settings.postgres_db
|
|
@@ -146,21 +110,15 @@ def _postgres_identity(*, user: str | None, database: str | None) -> tuple[str,
|
|
|
146
110
|
def postgres_group(ctx: click.Context, postgres_args: tuple[str, ...]) -> None:
|
|
147
111
|
"""Run psql or PostgreSQL helper commands against the project database.
|
|
148
112
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
- Direct psql arguments passthrough
|
|
153
|
-
|
|
154
|
-
Args:
|
|
155
|
-
ctx: Click context object.
|
|
156
|
-
postgres_args: Additional arguments passed to psql or subcommands.
|
|
113
|
+
With no subcommand, opens an interactive psql session; subcommands cover
|
|
114
|
+
query, dump, restore, and vacuum, and remaining arguments pass through to
|
|
115
|
+
psql.
|
|
157
116
|
|
|
158
117
|
Example:
|
|
159
118
|
$ phlo postgres # Interactive psql
|
|
160
119
|
$ phlo postgres -c "SELECT 1" # One-off query via psql
|
|
161
120
|
$ phlo postgres query "SELECT * FROM users"
|
|
162
121
|
$ phlo postgres dump --file backup.sql.gz
|
|
163
|
-
|
|
164
122
|
"""
|
|
165
123
|
if postgres_args and postgres_args[0] == "query":
|
|
166
124
|
postgres_query.main(
|
|
@@ -221,23 +179,12 @@ def postgres_query(
|
|
|
221
179
|
) -> None:
|
|
222
180
|
"""Execute a SQL query against the running PostgreSQL service.
|
|
223
181
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
Args:
|
|
228
|
-
query: SQL query string to execute.
|
|
229
|
-
query_file: Path to file containing SQL query.
|
|
230
|
-
user: Database user (default from settings).
|
|
231
|
-
database: Database name (default from settings).
|
|
232
|
-
timeout_seconds: Maximum time to wait for query completion.
|
|
233
|
-
|
|
234
|
-
Raises:
|
|
235
|
-
click.ClickException: If the query fails or times out.
|
|
182
|
+
Accepts inline SQL or a query file and prints results to stdout. Raise
|
|
183
|
+
ClickException when the query fails or times out.
|
|
236
184
|
|
|
237
185
|
Example:
|
|
238
186
|
$ phlo postgres query "SELECT * FROM users"
|
|
239
187
|
$ phlo postgres query --file query.sql --timeout 60
|
|
240
|
-
|
|
241
188
|
"""
|
|
242
189
|
_require_container_backend()
|
|
243
190
|
enforce_surface_mutation_authorization("postgres.query", get_postgres_cli_adapter)
|
|
@@ -287,22 +234,12 @@ def postgres_dump(
|
|
|
287
234
|
) -> None:
|
|
288
235
|
"""Create a PostgreSQL logical backup (pg_dump) to a local file.
|
|
289
236
|
|
|
290
|
-
Dumps the entire database
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
output_file: Path to write the dump. Use .gz extension for compression.
|
|
295
|
-
user: Database user (default from settings).
|
|
296
|
-
database: Database name (default from settings).
|
|
297
|
-
timeout_seconds: Maximum time to wait for dump completion.
|
|
298
|
-
|
|
299
|
-
Raises:
|
|
300
|
-
click.ClickException: If the dump fails or times out.
|
|
237
|
+
Dumps the entire database, gzip-compressing when the output file ends in
|
|
238
|
+
``.gz``. Raise ClickException when the dump fails or times out.
|
|
301
239
|
|
|
302
240
|
Example:
|
|
303
241
|
$ phlo postgres dump --file backup.sql
|
|
304
242
|
$ phlo postgres dump --file backup.sql.gz --timeout 300
|
|
305
|
-
|
|
306
243
|
"""
|
|
307
244
|
_require_container_backend()
|
|
308
245
|
enforce_surface_mutation_authorization("postgres.dump", get_postgres_cli_adapter)
|
|
@@ -360,25 +297,16 @@ def postgres_restore(
|
|
|
360
297
|
) -> None:
|
|
361
298
|
"""Restore a PostgreSQL database from a logical backup file.
|
|
362
299
|
|
|
363
|
-
|
|
364
|
-
Uses psql internally to execute the dump SQL.
|
|
300
|
+
Uses psql to execute a plain or gzip-compressed SQL dump.
|
|
365
301
|
|
|
366
302
|
Warning:
|
|
367
303
|
This may overwrite existing data. Use with caution on production databases.
|
|
368
304
|
|
|
369
|
-
|
|
370
|
-
input_file: Path to the dump file (.sql or .sql.gz).
|
|
371
|
-
user: Database user (default from settings).
|
|
372
|
-
database: Database name (default from settings).
|
|
373
|
-
timeout_seconds: Maximum time to wait for restore completion.
|
|
374
|
-
|
|
375
|
-
Raises:
|
|
376
|
-
click.ClickException: If the restore fails or times out.
|
|
305
|
+
Raise ClickException when the restore fails or times out.
|
|
377
306
|
|
|
378
307
|
Example:
|
|
379
308
|
$ phlo postgres restore --file backup.sql
|
|
380
309
|
$ phlo postgres restore --file backup.sql.gz --db mydb --timeout 600
|
|
381
|
-
|
|
382
310
|
"""
|
|
383
311
|
_require_container_backend()
|
|
384
312
|
enforce_surface_mutation_authorization("postgres.restore", get_postgres_cli_adapter)
|
|
@@ -427,23 +355,13 @@ def postgres_vacuum(
|
|
|
427
355
|
) -> None:
|
|
428
356
|
"""Run vacuumdb for PostgreSQL maintenance inside the container.
|
|
429
357
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
Args:
|
|
434
|
-
user: Database user (default from settings).
|
|
435
|
-
database: Database name (default from settings).
|
|
436
|
-
analyze: Whether to run ANALYZE after vacuum (updates statistics).
|
|
437
|
-
timeout_seconds: Maximum time to wait for vacuum completion.
|
|
438
|
-
|
|
439
|
-
Raises:
|
|
440
|
-
click.ClickException: If vacuum fails or times out.
|
|
358
|
+
Reclaims storage and optionally ANALYZEs afterwards to refresh statistics.
|
|
359
|
+
Raise ClickException when vacuum fails or times out.
|
|
441
360
|
|
|
442
361
|
Example:
|
|
443
362
|
$ phlo postgres vacuum
|
|
444
363
|
$ phlo postgres vacuum --no-analyze
|
|
445
364
|
$ phlo postgres vacuum --db analytics --timeout 300
|
|
446
|
-
|
|
447
365
|
"""
|
|
448
366
|
_require_container_backend()
|
|
449
367
|
enforce_surface_mutation_authorization("postgres.vacuum", get_postgres_cli_adapter)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Postgres CLI plugin registration.
|
|
2
|
+
|
|
3
|
+
Exposes the postgres command group as a cli-command plugin so the
|
|
4
|
+
phlo-postgres package contributes its commands through plugin discovery
|
|
5
|
+
rather than a core-CLI import.
|
|
6
|
+
Loaded through the phlo plugin entry-point mechanism at startup rather than
|
|
7
|
+
imported directly; exposes the command group from phlo_postgres.cli.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from phlo.plugins.base import cli_command_plugin_class
|
|
14
|
+
|
|
15
|
+
from phlo_postgres.cli import postgres_group
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PostgresCliPlugin = cli_command_plugin_class(
|
|
19
|
+
"PostgresCliPlugin",
|
|
20
|
+
name="postgres",
|
|
21
|
+
version="0.1.0",
|
|
22
|
+
description="CLI commands for PostgreSQL service access",
|
|
23
|
+
commands=[postgres_group],
|
|
24
|
+
)
|