fix(translate): three audit fixes — dict hash, SQL chunking, duplicate logic
1. dict_snapshot_hash now includes entry count + max(updated_at) per dictionary. Previously only hashed dictionary IDs, meaning edits to dictionary entries did NOT invalidate the translation cache. Stale cached translations could be served after editing dictionary entries. (HIGH severity) 2. _batch_insert.py now uses SQLGenerator.generate_batch() with 500-row chunking instead of a single massive INSERT statement. Prevents potential SQL size limit issues with large batches or many target languages. (LOW severity) 3. Fixed same bug in preview_response_parser.py — compute_dict_snapshot_hash had identical ID-only hash flaw. Tests: 69/69 translate tests pass.
This commit is contained in:
@@ -49,11 +49,25 @@ def insert_batch_to_target(
|
||||
if dialect is None:
|
||||
return
|
||||
|
||||
sql, row_count = _generate_insert_sql(dialect, job, columns, rows_for_sql, batch_id)
|
||||
if sql is None:
|
||||
return
|
||||
|
||||
_execute_insert_sql(executor, sql, batch_id, row_count)
|
||||
# Use chunked INSERT to avoid SQL size limits (max 500 VALUES rows per statement)
|
||||
statements = SQLGenerator.generate_batch(
|
||||
dialect=dialect,
|
||||
target_schema=job.target_schema,
|
||||
target_table=job.target_table or "translated_data",
|
||||
columns=columns,
|
||||
rows=rows_for_sql,
|
||||
key_columns=job.target_key_cols,
|
||||
upsert_strategy=job.upsert_strategy or "MERGE",
|
||||
max_rows_per_statement=500,
|
||||
)
|
||||
total_inserted = 0
|
||||
for sql, chunk_count in statements:
|
||||
if sql is None:
|
||||
continue
|
||||
_execute_insert_sql(executor, sql, batch_id, chunk_count)
|
||||
total_inserted += chunk_count
|
||||
logger.reason(f"Batch {batch_id[:12]} inserted {total_inserted} rows",
|
||||
{"batch_id": batch_id, "rows": total_inserted, "chunks": len(statements)})
|
||||
# #endregion insert_batch_to_target
|
||||
|
||||
|
||||
|
||||
@@ -30,18 +30,40 @@ def compute_config_hash(job) -> str:
|
||||
|
||||
# #region compute_dict_snapshot_hash [C:2] [TYPE Function] [SEMANTICS dictionary, hash, snapshot]
|
||||
# @BRIEF Compute a hash of dictionary state for snapshot comparison.
|
||||
# Includes dictionary IDs, entry count, and max entry updated_at
|
||||
# to invalidate cache when dictionary entries are modified.
|
||||
# @PRE db_session is valid.
|
||||
# @POST Returns 16-char hex SHA-256 hash of concatenated dictionary IDs.
|
||||
# @POST Returns 16-char hex SHA-256 hash.
|
||||
def compute_dict_snapshot_hash(db_session, job_id: str) -> str:
|
||||
"""Compute a hash of dictionary state for snapshot comparison."""
|
||||
from ...models.translate import TranslationJobDictionary
|
||||
from ...models.translate import TranslationJobDictionary, DictionaryEntry
|
||||
from sqlalchemy import func
|
||||
|
||||
dict_links = (
|
||||
db_session.query(TranslationJobDictionary)
|
||||
.filter(TranslationJobDictionary.job_id == job_id)
|
||||
.all()
|
||||
)
|
||||
dict_ids = sorted([dl.dictionary_id for dl in dict_links])
|
||||
hash_input = ",".join(dict_ids)
|
||||
if not dict_links:
|
||||
return hashlib.sha256(b"").hexdigest()[:16]
|
||||
|
||||
parts: list[str] = []
|
||||
for dl in sorted(dict_links, key=lambda d: d.dictionary_id or ""):
|
||||
# Include entry count + max updated_at to detect content changes
|
||||
entry_count = (
|
||||
db_session.query(func.count(DictionaryEntry.id))
|
||||
.filter(DictionaryEntry.dictionary_id == dl.dictionary_id)
|
||||
.scalar()
|
||||
)
|
||||
max_updated = (
|
||||
db_session.query(func.max(DictionaryEntry.updated_at))
|
||||
.filter(DictionaryEntry.dictionary_id == dl.dictionary_id)
|
||||
.scalar()
|
||||
)
|
||||
max_updated_str = max_updated.isoformat() if max_updated else "0"
|
||||
parts.append(f"{dl.dictionary_id}:c{entry_count}:u{max_updated_str}")
|
||||
|
||||
hash_input = "|".join(parts)
|
||||
return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
|
||||
# #endregion compute_dict_snapshot_hash
|
||||
# #endregion orchestrator_config
|
||||
|
||||
@@ -123,15 +123,36 @@ def compute_config_hash(job: TranslationJob) -> str:
|
||||
|
||||
# #region compute_dict_snapshot_hash [C:2] [TYPE Function] [SEMANTICS dictionary, hash, snapshot]
|
||||
# @BRIEF Compute a hash of dictionary state for snapshot comparison.
|
||||
# Includes dictionary IDs, entry count, and max entry updated_at.
|
||||
def compute_dict_snapshot_hash(db: Session, job_id: str) -> str:
|
||||
"""Compute a hash of dictionary state for snapshot comparison."""
|
||||
from sqlalchemy import func
|
||||
from ...models.translate import TranslationJobDictionary, DictionaryEntry
|
||||
|
||||
dict_links = (
|
||||
db.query(TranslationJobDictionary)
|
||||
.filter(TranslationJobDictionary.job_id == job_id)
|
||||
.all()
|
||||
)
|
||||
dict_ids = sorted([dl.dictionary_id for dl in dict_links])
|
||||
hash_input = ",".join(dict_ids)
|
||||
if not dict_links:
|
||||
return hashlib.sha256(b"").hexdigest()[:16]
|
||||
|
||||
parts: list[str] = []
|
||||
for dl in sorted(dict_links, key=lambda d: d.dictionary_id or ""):
|
||||
entry_count = (
|
||||
db.query(func.count(DictionaryEntry.id))
|
||||
.filter(DictionaryEntry.dictionary_id == dl.dictionary_id)
|
||||
.scalar()
|
||||
)
|
||||
max_updated = (
|
||||
db.query(func.max(DictionaryEntry.updated_at))
|
||||
.filter(DictionaryEntry.dictionary_id == dl.dictionary_id)
|
||||
.scalar()
|
||||
)
|
||||
max_updated_str = max_updated.isoformat() if max_updated else "0"
|
||||
parts.append(f"{dl.dictionary_id}:c{entry_count}:u{max_updated_str}")
|
||||
|
||||
hash_input = "|".join(parts)
|
||||
return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
|
||||
# #endregion compute_dict_snapshot_hash
|
||||
# #endregion preview_response_parser
|
||||
|
||||
Reference in New Issue
Block a user