update_library.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """Authorized batch merge into a new immutable generation, then atomic pointer switch.
  2. Incoming databases must first be built with the existing validated VOC importer.
  3. Default is a dry plan. Readers continue using the old generation until --apply succeeds.
  4. """
  5. import sys
  6. sys.dont_write_bytecode=True
  7. import argparse
  8. import hashlib
  9. import json
  10. import os
  11. from pathlib import Path
  12. import shutil
  13. import sqlite3
  14. import tempfile
  15. import uuid
  16. import voc_db
  17. import retrieval
  18. from product_insights import ANNOTATION_DDL
  19. ORDER=['batches','assets','collection_requests','brands','categories','source_records','source_links','products',
  20. 'classification_assertions','product_observations','specs','prices','price_sources','reviews','social_posts',
  21. 'social_brand_mentions','policy_documents','policy_clauses','ranking_signals','coverage_observations','quality_issues','review_theme_annotations']
  22. STABLE={'products','reviews','social_posts','policy_documents','policy_clauses'}
  23. def digest(p):
  24. with Path(p).open('rb') as f:return voc_db.file_sha256(f)
  25. def canonical(v):return json.dumps(v,ensure_ascii=False,sort_keys=True,separators=(',',':'))
  26. def verified(p):
  27. with voc_db.readonly(p) as c:
  28. voc_db.require(c.execute('PRAGMA integrity_check').fetchone()[0]=='ok' and not c.execute('PRAGMA foreign_key_check').fetchall(),'DATABASE_CHECK_FAILED')
  29. voc_db.require(c.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]==sum(c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in retrieval.ENTITIES.values()),'RETRIEVAL_COUNT_MISMATCH')
  30. for table in ORDER:
  31. if table=='review_theme_annotations':continue # Optional, backward-compatible enrichment.
  32. voc_db.require(c.execute('SELECT sql FROM sqlite_master WHERE type=? AND name=?',('table',table)).fetchone() is not None,'MISSING_TABLE:'+table)
  33. return dict(c.execute('SELECT * FROM meta'))
  34. def merge(current,incoming,dest):
  35. im=verified(incoming)
  36. if not current:shutil.copyfile(incoming,dest)
  37. else:
  38. with voc_db.readonly(current) as old:
  39. con=sqlite3.connect(dest);old.backup(con);con.close()
  40. c=sqlite3.connect(dest);c.row_factory=sqlite3.Row;c.execute('PRAGMA foreign_keys=ON')
  41. maps={};delta={'added':{},'changed':{},'duplicate':{}}
  42. try:
  43. c.executescript(ANNOTATION_DDL)
  44. c.execute('CREATE TABLE IF NOT EXISTS source_origins(source_record_id TEXT NOT NULL REFERENCES source_records(id),archive_sha256 TEXT NOT NULL,PRIMARY KEY(source_record_id,archive_sha256))')
  45. c.execute('CREATE TABLE IF NOT EXISTS record_versions(entity TEXT NOT NULL,record_id TEXT NOT NULL,batch_sha256 TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(entity,record_id,batch_sha256))')
  46. baseline=dict(c.execute('SELECT * FROM meta'))
  47. c.execute('INSERT OR IGNORE INTO source_origins SELECT id,? FROM source_records',(baseline['archive_sha256'],))
  48. if current:
  49. with voc_db.readonly(incoming) as inc:
  50. for table in ORDER:
  51. if not inc.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",(table,)).fetchone():continue
  52. info=c.execute('PRAGMA table_info('+table+')').fetchall()
  53. pk=[r['name'] for r in sorted(info,key=lambda r:r['pk']) if r['pk']]
  54. fks=[dict(r) for r in c.execute('PRAGMA foreign_key_list('+table+')')]
  55. mapping={};maps[table]=mapping
  56. for original in inc.execute('SELECT * FROM '+table):
  57. row=dict(original)
  58. for fk in fks:
  59. value=row[fk['from']]
  60. if value is not None:row[fk['from']]=maps.get(fk['table'],{}).get(value,value)
  61. if table=='quality_issues' and row['entity_type']=='source':
  62. row['entity_id']=maps.get('source_records',{}).get(row['entity_id'],row['entity_id'])
  63. columns=list(row)
  64. old=c.execute('SELECT * FROM '+table+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in pk]).fetchone()
  65. if table=='batches' and old and old['archive_sha256']==row['archive_sha256']:
  66. mapping[original['id']]=old['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
  67. # The same timestamped price observation may be re-exported under a new source locator.
  68. # Unknown-time observations only deduplicate with identical source bytes.
  69. if table=='prices' and not old:
  70. fields=[k for k in columns if k not in ('id','source_record_id','collected_at_raw','collected_at_utc')]
  71. sql='SELECT p.id FROM prices p JOIN source_records s ON s.id=p.source_record_id WHERE '+' AND '.join('p.'+k+' IS ?' for k in fields)
  72. vals=[row[k] for k in fields]
  73. if not row.get('source_time_raw'):
  74. sql+=' AND s.payload_sha256=(SELECT payload_sha256 FROM source_records WHERE id=?)';vals.append(row['source_record_id'])
  75. same_price=c.execute(sql+' LIMIT 1',vals).fetchone()
  76. if same_price:
  77. mapping[original['id']]=same_price['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
  78. if table in ('brands','categories') and old:
  79. voc_db.require(dict(old)==row,'TAXONOMY_CONFLICT:'+table+':'+str(row[pk[0]]))
  80. same=old and all(old[k]==v for k,v in row.items() if k!='source_record_id')
  81. if same:
  82. delta['duplicate'][table]=delta['duplicate'].get(table,0)+1
  83. if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
  84. continue
  85. if old and table not in STABLE:
  86. voc_db.require(len(pk)==1 and pk[0] in ('id','path','asset_path'),'CONFLICTING_RELATION:'+table)
  87. col=pk[0]
  88. if table=='assets':row[col]='batches/'+im['archive_sha256'][:16]+'/'+row[col]
  89. elif table=='collection_requests':raise ValueError('CONFLICTING_COLLECTION_REQUEST')
  90. else:row[col]=hashlib.sha256((im['generation']+':'+str(original[col])).encode()).hexdigest()
  91. existing=c.execute('SELECT * FROM '+table+' WHERE '+col+'=?',(row[col],)).fetchone()
  92. voc_db.require(existing is None or dict(existing)==row,'BATCH_ID_COLLISION')
  93. if existing:
  94. mapping[original[col]]=row[col];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
  95. old=None
  96. if old:
  97. for version,batch in [(dict(old),baseline['archive_sha256']),(row,im['archive_sha256'])]:
  98. c.execute('INSERT OR IGNORE INTO record_versions VALUES(?,?,?,?)',(table,str(row[pk[0]]),batch,canonical(version)))
  99. c.execute('UPDATE '+table+' SET '+','.join(k+'=?' for k in columns if k not in pk)+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in columns if k not in pk]+[row[k] for k in pk])
  100. delta['changed'][table]=delta['changed'].get(table,0)+1
  101. else:
  102. c.execute('INSERT INTO '+table+'('+','.join(columns)+') VALUES('+','.join('?' for _ in columns)+')',[row[k] for k in columns])
  103. delta['added'][table]=delta['added'].get(table,0)+1
  104. if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
  105. for sid in maps['source_records'].values():c.execute('INSERT OR IGNORE INTO source_origins VALUES(?,?)',(sid,im['archive_sha256']))
  106. # Merge dictionaries from the actual validated rows, retaining prior definitions.
  107. tax=json.loads(baseline['taxonomy'])
  108. for key in ('brands','categories'):
  109. known={r['code'] for r in tax[key]}
  110. tax[key].extend(r for r in json.loads(im['taxonomy'])[key] if r['code'] not in known)
  111. c.execute("UPDATE meta SET value=? WHERE key='taxonomy'",(canonical(tax),))
  112. for name in ('retrieval_fts','retrieval_documents'):c.execute('DROP TABLE '+name)
  113. retrieval.create_index(c)
  114. generation=hashlib.sha256(canonical([baseline['generation'],im['generation'],digest(Path(__file__)),delta]).encode()).hexdigest()
  115. c.execute("UPDATE meta SET value=? WHERE key='generation'",(generation,))
  116. else:
  117. delta['added']={t:c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in ORDER}
  118. c.commit()
  119. finally:c.close()
  120. verified(dest)
  121. return delta
  122. def publish(incoming,registry,apply=False,label='资料更新',rollback=None,expected_generation=None):
  123. root=Path(registry).resolve();source=Path(incoming).resolve() if incoming else None
  124. pointer=root/'current.json'
  125. prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
  126. if not apply:
  127. voc_db.require(expected_generation is None or (prior or {}).get('generation')==expected_generation,'LIBRARY_GENERATION_CHANGED')
  128. if rollback:
  129. voc_db.require(prior and any(x['generation']==rollback for x in prior['history']),'VERSION_NOT_FOUND')
  130. return {'ok':True,'readOnly':True,'rollbackGeneration':rollback}
  131. meta=verified(source)
  132. return {'ok':True,'readOnly':True,'incomingGeneration':meta['generation'],'currentGeneration':prior['generation'] if prior else None,'mode':'merge-batch' if prior else 'initial','label':label}
  133. root.mkdir(parents=True,exist_ok=True);os.chmod(root,0o700)
  134. lock=root/'.update.lock';fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
  135. temp=None;tmp=None
  136. try:
  137. # Re-read after the exclusive lock. Query readers never need this lock.
  138. prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
  139. voc_db.require(expected_generation is None or (prior or {}).get('generation')==expected_generation,'LIBRARY_GENERATION_CHANGED')
  140. if rollback:
  141. entry=next((x for x in (prior or {}).get('history',[]) if x['generation']==rollback),None)
  142. voc_db.require(entry is not None,'VERSION_NOT_FOUND')
  143. target=(root/entry['file']).resolve();voc_db.require(target.is_relative_to(root),'INVALID_VERSION_PATH')
  144. voc_db.require(digest(target)==entry['databaseSha256'],'ROLLBACK_HASH_MISMATCH');verified(target)
  145. result={**entry,'label':'回退至 '+rollback[:12],'updatedAt':voc_db.now(),'history':prior['history']}
  146. else:
  147. meta=verified(source)
  148. if prior and any(x.get('incomingGeneration')==meta['generation'] for x in prior['history']):
  149. return {'ok':True,'applied':False,'operation':'already-imported','generation':prior['generation']}
  150. current=(root/prior['file']).resolve() if prior else None
  151. if current:
  152. voc_db.require(current.is_relative_to(root) and digest(current)==prior['databaseSha256'],'CURRENT_HASH_MISMATCH')
  153. temp=root/('.build-'+str(uuid.uuid4())+'.sqlite')
  154. delta=merge(current,source,temp)
  155. finalmeta=verified(temp);generation=finalmeta['generation']
  156. versions=root/'versions';versions.mkdir(exist_ok=True);os.chmod(versions,0o700)
  157. target=versions/(generation+'.sqlite')
  158. if target.exists():
  159. voc_db.require(digest(target)==digest(temp),'VERSION_ALREADY_EXISTS_DIFFERENT_CONTENT');temp.unlink();temp=None
  160. else:
  161. os.chmod(temp,0o600);temp.rename(target);temp=None
  162. names={'products':'商品','reviews':'评价','prices':'价格观察','specs':'规格','social_posts':'社媒内容','policy_documents':'政策文件','policy_clauses':'条款','ranking_signals':'榜单线索','coverage_observations':'覆盖记录'}
  163. summary='、'.join(names.get(t,t)+'新增'+str(delta['added'].get(t,0))+'/变更'+str(delta['changed'].get(t,0)) for t in retrieval.ENTITIES.values() if delta['added'].get(t,0) or delta['changed'].get(t,0)) or '重复资料,无实体新增'
  164. entry={'generation':generation,'incomingGeneration':meta['generation'],'file':target.relative_to(root).as_posix(),'databaseSha256':digest(target),'updatedAt':voc_db.now(),'label':label,'summary':summary,'delta':delta}
  165. result={**entry,'history':[entry,*((prior or {}).get('history',[]))]}
  166. tmp=root/('.pointer-'+str(uuid.uuid4())+'.json')
  167. with tmp.open('x',encoding='utf-8') as f:f.write(canonical(result)+'\n');f.flush();os.fsync(f.fileno())
  168. os.chmod(tmp,0o600);os.replace(tmp,pointer);tmp=None
  169. return {'ok':True,'applied':True,**result}
  170. finally:
  171. os.close(fd);lock.unlink()
  172. if temp and temp.exists():temp.unlink()
  173. if tmp and tmp.exists():tmp.unlink()
  174. if __name__=='__main__':
  175. p=argparse.ArgumentParser(description=__doc__);p.add_argument('--incoming');p.add_argument('--registry',required=True);p.add_argument('--label',default='资料更新');p.add_argument('--apply',action='store_true');p.add_argument('--rollback');p.add_argument('--expected-generation')
  176. args=p.parse_args()
  177. try:print(json.dumps(publish(args.incoming,args.registry,args.apply,args.label,args.rollback,args.expected_generation),ensure_ascii=False,indent=2))
  178. except Exception as e:print(json.dumps({'ok':False,'error':str(e)},ensure_ascii=False),file=sys.stderr);sys.exit(1)