Skip to content

fix(consolidation): guard observation_history append on 0-row observation UPDATE - #3161

Merged
nicoloboschi merged 2 commits into
mainfrom
fix/update-action-fk-guard-clean
Aug 4, 2026
Merged

fix(consolidation): guard observation_history append on 0-row observation UPDATE#3161
nicoloboschi merged 2 commits into
mainfrom
fix/update-action-fk-guard-clean

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Summary

When consolidation updates an existing observation, _execute_update_action
runs source-liveness checks (a non-locking preflight plus a FOR SHARE
recheck) — but those guard only the source memories. The observation row
itself (UPDATE memory_units ... WHERE id = $5) can still be concurrently
invalidated/deleted, so the UPDATE matches 0 rows. The code then fell
through to _append_observation_history, whose INSERT carries an
observation_id FK onto memory_units. Appending history for a now-missing
row raises ForeignKeyViolationError — a (correctly) non-retryable integrity
failure that marks the whole consolidation operation failed for a row that
simply no longer exists.

This captures the UPDATE command status in the SQL branch and bails out
(return None) before writing history when 0 rows matched. The
store/upsert branch is an upsert and cannot hit the 0-row case, so it needs no
guard. The Oracle DatabaseConnection wrapper reshapes rowcount into the same
"UPDATE <n>" command-tag form, so the int(status.split()[-1]) parse is
dialect-safe (same idiom already used in config_resolver.py).

Relationship to #3094

This supersedes #3094, which diagnosed the same FK race correctly but:

  • added the guard twice — once inside the SQL branch and again,
    unconditionally, after the if/else; and
  • the second copy references update_status, which is only assigned in the
    SQL branch
    . On the upsert/non-SQL path (writes_memory_rows_in_sql=False)
    that variable is unbound, so the block raises UnboundLocalError on every
    update through that path — turning a rare, correct-to-skip race into an
    unconditional crash. Its tests only drove the SQL branch, so this was not
    caught.

This PR keeps a single guard in the only branch that can 0-row, and is written
against the current main shape of _execute_update_action (pool-based,
with the preflight + FOR SHARE liveness checks that #3094's base predated).

Validation

Mock-level regression tests in tests/test_integrity_violation_not_retried.py:

  • test_update_action_bails_when_observation_row_missing — UPDATE returns
    "UPDATE 0"; asserts the function returns None and _append_observation_history
    is not called (the FK-violating INSERT is skipped).
  • test_update_action_writes_history_when_row_present — positive control:
    "UPDATE 1"; asserts history is appended and the embedding is returned.

Mutation-checked: reverting the guard turns the first test red (history append
fires) while the positive control stays green. ruff, ruff format, and ty
all pass.

The source-liveness checks in _execute_update_action guard the *source*
memories, but the observation row itself (UPDATE ... WHERE id = $5) can be
concurrently invalidated/deleted, matching 0 rows. The code then fell through
to _append_observation_history, whose INSERT carries an observation_id FK onto
memory_units — raising ForeignKeyViolationError, a (correctly) non-retryable
integrity failure that marked the whole consolidation op failed for a row that
simply no longer exists.

Capture the UPDATE status in the SQL branch and bail out (return None) before
the history append when 0 rows matched. The store/upsert branch cannot hit the
0-row case, so it needs no guard. The Oracle wrapper reshapes rowcount into the
same "UPDATE <n>" form, so the parse is dialect-safe (mirrors config_resolver).

Adds mock-level regression tests covering both the 0-row bail and the
positive (rowcount==1) control.
…ow guard

Move the command-tag rowcount parse out of consolidation business logic and
into the pg/oracle connection layer. DatabaseConnection.execute_rows_affected
runs a DML statement and returns a plain int, normalizing the dialect-divergent
result shape the same way parse_json normalizes JSON columns: asyncpg returns
the tag directly, the Oracle connection reshapes cursor.rowcount into the same
trailing-count form, so parsing the last token is dialect-safe.

_execute_update_action now calls conn.execute_rows_affected(...) and checks the
int directly instead of hand-parsing an "UPDATE <n>" string. Adds a parser unit
test covering the tag shapes both dialects emit.
@nicoloboschi
nicoloboschi force-pushed the fix/update-action-fk-guard-clean branch from 60dd41b to e51e081 Compare August 4, 2026 14:15
@nicoloboschi
nicoloboschi merged commit c335192 into main Aug 4, 2026
205 of 208 checks passed
@nicoloboschi
nicoloboschi deleted the fix/update-action-fk-guard-clean branch August 4, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant