mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 04:15:35 +00:00
fix(db): make compression-point deduplication atomic
The duplicate check for append_compression_point ran as a SELECT before the UPDATE, so two sessions persisting the same point could both pass it. The predicate now lives inside the UPDATE's CASE expression and is evaluated under the row lock; a point without query_index or compressed_summary never matches and is always appended. Adds a two-session regression test that races the same point and expects one.
This commit is contained in:
1 parent
1f86139b9c
commit
fdba261d11
2 files changed
+78
-38
No files matched your search
@@ -508,49 +508,47 @@ class ConversationsRepository:
|
||||
return False
|
||||
# Idempotent on the same point: two persist paths (the mid-execution
|
||||
# handler and the route's end-of-stream save) used to both append it.
|
||||
last_point = self._conn.execute(
|
||||
text(
|
||||
"SELECT compression_metadata -> 'compression_points' -> -1 "
|
||||
"FROM conversations WHERE id = CAST(:id AS uuid)"
|
||||
),
|
||||
{"id": conversation_id},
|
||||
).scalar()
|
||||
if (
|
||||
isinstance(last_point, dict)
|
||||
and point.get("query_index") is not None
|
||||
and point.get("compressed_summary") is not None
|
||||
and last_point.get("query_index") == point.get("query_index")
|
||||
and last_point.get("compressed_summary") == point.get("compressed_summary")
|
||||
):
|
||||
return True
|
||||
# The duplicate check lives inside the UPDATE so it runs under the
|
||||
# row lock — a separate SELECT would let two sessions both pass it.
|
||||
# A point without ``query_index``/``compressed_summary`` never matches
|
||||
# (NULL comparisons are false), so it is always appended.
|
||||
dup_index = point.get("query_index")
|
||||
dup_summary = point.get("compressed_summary")
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE conversations SET
|
||||
compression_metadata = jsonb_set(
|
||||
COALESCE(compression_metadata, '{}'::jsonb),
|
||||
'{compression_points}',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(elem ORDER BY rn)
|
||||
FROM (
|
||||
SELECT
|
||||
elem,
|
||||
row_number() OVER () AS rn,
|
||||
count(*) OVER () AS cnt
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(
|
||||
compression_metadata -> 'compression_points',
|
||||
'[]'::jsonb
|
||||
) || jsonb_build_array(CAST(:point AS jsonb))
|
||||
) AS elem
|
||||
) ranked
|
||||
WHERE rn > cnt - :max_points
|
||||
compression_metadata = CASE
|
||||
WHEN (compression_metadata -> 'compression_points' -> -1
|
||||
->> 'query_index') = CAST(:dup_index AS text)
|
||||
AND (compression_metadata -> 'compression_points' -> -1
|
||||
->> 'compressed_summary') = CAST(:dup_summary AS text)
|
||||
THEN compression_metadata
|
||||
ELSE jsonb_set(
|
||||
COALESCE(compression_metadata, '{}'::jsonb),
|
||||
'{compression_points}',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(elem ORDER BY rn)
|
||||
FROM (
|
||||
SELECT
|
||||
elem,
|
||||
row_number() OVER () AS rn,
|
||||
count(*) OVER () AS cnt
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(
|
||||
compression_metadata -> 'compression_points',
|
||||
'[]'::jsonb
|
||||
) || jsonb_build_array(CAST(:point AS jsonb))
|
||||
) AS elem
|
||||
) ranked
|
||||
WHERE rn > cnt - :max_points
|
||||
),
|
||||
'[]'::jsonb
|
||||
),
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
true
|
||||
)
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
@@ -559,6 +557,8 @@ class ConversationsRepository:
|
||||
"id": conversation_id,
|
||||
"point": json.dumps(point, cls=PGNativeJSONEncoder),
|
||||
"max_points": int(max_points),
|
||||
"dup_index": None if dup_index is None else str(dup_index),
|
||||
"dup_summary": None if dup_summary is None else str(dup_summary),
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
Reference in new issue
Block a user