payroll-source-index-concurrency.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. // Manual acceptance helper for a PRIVATE cloned schema. Import into the existing
  2. // fixed diagnostic gateway harness; this module opens no connection or remote run.
  3. // Pass real SQL scripts as strings. The clone must already contain the native
  4. // source tables, original payroll functions, and financial rule/reward tables.
  5. export async function installPayrollSourceIndexConcurrency(Psql, config) {
  6. const {schema,incrementalSql,indexSql}=config;
  7. if(!/^speed3_[a-z0-9_]+$/.test(schema))throw Error('Private speed3_ clone schema required');
  8. const rewrite=sql=>sql.replace(/^BEGIN;\s*$/m,'').replace(/^COMMIT;\s*$/m,'').replaceAll("n.nspname='public'",`n.nspname='${schema}'`).replaceAll('public.',schema+'.');
  9. return Psql.tx(async tx=>{
  10. await tx.none(`SET LOCAL search_path=${schema},public`);
  11. await tx.none("SET LOCAL statement_timeout='120s';SET LOCAL jit=off;SET LOCAL work_mem='64MB';SET LOCAL TimeZone='Etc/GMT-8';SET LOCAL extra_float_digits=1");
  12. // CREATE TABLE AS clones do not retain the digest primary key. The real
  13. // round-2 row trigger requires uniqueness for its atomic replacement.
  14. await tx.none('CREATE UNIQUE INDEX IF NOT EXISTS concurrency_digest_identity ON "PayrollReadDigest"(kind,company,"objectId")');
  15. await tx.any(rewrite(incrementalSql));
  16. await tx.any(rewrite(indexSql));
  17. const readiness=await tx.one('SELECT xs_payroll_authority_ready() authority,xs_payroll_digest_ready() digest,xs_payroll_source_index_ready() projection');
  18. if(!readiness.authority||!readiness.digest||!readiness.projection)throw Error('Real authority/digest/projection guards are not ready');
  19. return readiness;
  20. });
  21. }
  22. // Self-contained so an existing diagnostic gateway can serialize this function.
  23. // Runs two REAL concurrent SQL transactions. A source writer which loses the
  24. // target advisory lock must commit without waiting for projection upkeep. Its
  25. // newer committed source is then read correctly despite the older index writer
  26. // committing last. The original and indexed reads share one RR snapshot.
  27. export async function payrollSourceIndexConcurrentAcceptance(Psql,config) {
  28. const {schema,company}=config;
  29. if(!/^speed3_[a-z0-9_]+$/.test(schema))throw Error('Private speed3_ clone schema required');
  30. const prepare=async(tx,isolation='READ COMMITTED')=>{
  31. await tx.none(`SET TRANSACTION ISOLATION LEVEL ${isolation}`);
  32. await tx.none(`SET LOCAL search_path=${schema},public`);
  33. await tx.none("SET LOCAL statement_timeout='30s';SET LOCAL lock_timeout='3s';SET LOCAL jit=off;SET LOCAL work_mem='64MB';SET LOCAL TimeZone='Etc/GMT-8';SET LOCAL extra_float_digits=1");
  34. };
  35. const fixture=await Psql.tx(async tx=>{
  36. await prepare(tx);
  37. const guards=await tx.one(`SELECT xs_payroll_source_index_ready() ready,(SELECT count(*)::int FROM pg_trigger WHERE tgrelid IN ('"CommonModel"'::regclass,'"CourseAppointment"'::regclass,'"LessonRecord"'::regclass,'"MemoryPracticeRecord"'::regclass) AND tgname='xs_payroll_digest_row' AND tgenabled='A') triggers`);
  38. if(!guards.ready||guards.triggers!==4)throw Error('Real digest triggers must be installed; static profile stubs are insufficient');
  39. const found=await tx.oneOrNone(`SELECT l."objectId" lesson,l.pldp comment,a."objectId" appointment,a."durationMinutes" minutes FROM "LessonRecord" l JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."itemId"=a.id AND c."modelId"=54 WHERE a.company=l.company AND c."generalId"::text=l.yyds::text ORDER BY c."objectId" LIMIT 1) a ON true WHERE l.company=$1 ORDER BY l."objectId" LIMIT 1`,[company]);
  40. if(!found)throw Error('No real linked lesson/appointment fixture');
  41. return found;
  42. });
  43. const parity=async()=>Psql.tx(async tx=>{
  44. await prepare(tx,'REPEATABLE READ');
  45. await tx.none('SET TRANSACTION READ ONLY');
  46. const value=await tx.one(`SELECT xs_payroll_source_index_ready() ready,
  47. (SELECT md5(jsonb_agg(s)::text) FROM xs_payroll_sources($1) s) original_rows,
  48. (SELECT md5(jsonb_agg(s)::text) FROM xs_payroll_sources_indexed($1) s) indexed_rows,
  49. xs_payroll_source_version($1) original_version,xs_payroll_source_version_indexed($1) indexed_version`,[company]);
  50. if(!value.ready||value.original_rows!==value.indexed_rows||value.original_version!==value.indexed_version)throw Error('Concurrent read parity failed: '+JSON.stringify(value));
  51. return value;
  52. });
  53. const results=[];
  54. try {
  55. for(const isolation of ['READ COMMITTED','REPEATABLE READ']){
  56. let announce,release;
  57. const firstReady=new Promise(resolve=>{announce=resolve;}),secondCommitted=new Promise(resolve=>{release=resolve;});
  58. let firstError;
  59. const first=Psql.tx(async tx=>{
  60. await prepare(tx,isolation);
  61. await tx.none('UPDATE "LessonRecord" SET pldp=COALESCE(pldp,\'\')||$3 WHERE company=$1 AND "objectId"=$2',[company,fixture.lesson,' index-race-'+isolation]);
  62. announce();await secondCommitted;
  63. }).catch(error=>{firstError=error;announce();throw error;});
  64. // Attach a rejection handler before awaiting the independent writer.
  65. first.catch(()=>{});
  66. await firstReady;
  67. if(firstError){release();await first;}
  68. let writer,whileFirstUncommitted;
  69. try {
  70. writer=await Psql.tx(async tx=>{
  71. await prepare(tx);
  72. const lock=await tx.one("SELECT pg_try_advisory_xact_lock(hashtext('payroll-source-index'),hashtext($1||':lesson:'||$2)) acquired",[company,fixture.lesson]);
  73. if(lock.acquired)throw Error('First transaction did not hold the target projection lock');
  74. const started=Date.now();
  75. await tx.none('UPDATE "CourseAppointment" SET "durationMinutes"=COALESCE("durationMinutes",60)+1 WHERE company=$1 AND "objectId"=$2',[company,fixture.appointment]);
  76. return {targetLockSkipped:true,writeMs:Date.now()-started};
  77. });
  78. whileFirstUncommitted=await parity();
  79. } finally {release();await first;}
  80. const after=await parity();
  81. const stale=await Psql.tx(async tx=>{
  82. await prepare(tx,'REPEATABLE READ');
  83. return tx.one(`SELECT i.input_signature IS DISTINCT FROM p.input_signature stale FROM "PayrollSourceIndex" i CROSS JOIN LATERAL xs_payroll_source_index_target(i.company,i.kind,i."objectId") p WHERE i.company=$1 AND i.kind='lesson' AND i."objectId"=$2`,[company,fixture.lesson]);
  84. });
  85. if(!stale.stale)throw Error('Race failed to leave an older projection; did not exercise read-time repair');
  86. results.push({isolation,...writer,staleProjection:true,whileFirstUncommitted,after});
  87. }
  88. return {fixture:{lesson:fixture.lesson,appointment:fixture.appointment},results};
  89. } finally {
  90. await Psql.tx(async tx=>{
  91. await prepare(tx);
  92. await tx.none('UPDATE "LessonRecord" SET pldp=$3 WHERE company=$1 AND "objectId"=$2',[company,fixture.lesson,fixture.comment]);
  93. await tx.none('UPDATE "CourseAppointment" SET "durationMinutes"=$3 WHERE company=$1 AND "objectId"=$2',[company,fixture.appointment,fixture.minutes]);
  94. });
  95. }
  96. }
  97. // Actual UPDATE costs with all original digest triggers present. Each trial is
  98. // rolled back with a savepoint; disabling only the projection trigger supplies a
  99. // paired baseline without changing source data or old digest trigger definitions.
  100. export async function payrollSourceIndexWriterCosts(Psql,config) {
  101. const {schema,company}=config;
  102. if(!/^speed3_[a-z0-9_]+$/.test(schema))throw Error('Private speed3_ clone schema required');
  103. return Psql.tx(async tx=>{
  104. await tx.none(`SET LOCAL search_path=${schema},public`);
  105. await tx.none("SET LOCAL statement_timeout='60s';SET LOCAL lock_timeout='3s';SET LOCAL jit=off;SET LOCAL TimeZone='Etc/GMT-8';SET LOCAL extra_float_digits=1");
  106. const examples=await tx.any(`SELECT 'LessonRecord' AS table_name,"objectId" FROM "LessonRecord" WHERE company=$1 ORDER BY "objectId" LIMIT 3`,[company]);
  107. examples.push(...await tx.any(`SELECT 'CourseAppointment' AS table_name,a."objectId",count(*)::int linked_lessons FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id JOIN "LessonRecord" l ON l.company=a.company AND l.yyds::text=c."generalId"::text WHERE a.company=$1 GROUP BY a."objectId" ORDER BY count(*) DESC,a."objectId" LIMIT 3`,[company]));
  108. examples.push(...await tx.any(`SELECT DISTINCT ON ("modelId") 'CommonModel' AS table_name,"objectId","modelId"::int model FROM "CommonModel" WHERE company=$1 AND "modelId" IN(54,59,60) ORDER BY "modelId","objectId"`,[company]));
  109. const results=[];
  110. for(const [sample,row] of examples.entries()){
  111. for(const enabled of sample%2?[true,false]:[false,true]){
  112. await tx.none('SAVEPOINT writer_cost');
  113. try {
  114. if(!enabled)await tx.none(`ALTER TABLE "${row.table_name}" DISABLE TRIGGER xs_payroll_source_index_row`);
  115. const started=Date.now();
  116. const assignment=row.table_name==='LessonRecord'?"pldp=COALESCE(pldp,'')||' writer-cost'":row.table_name==='CourseAppointment'?'"durationMinutes"=COALESCE("durationMinutes",60)+1':"title=COALESCE(title,'')||' writer-cost'";
  117. await tx.none(`UPDATE "${row.table_name}" SET ${assignment} WHERE company=$1 AND "objectId"=$2`,[company,row.objectId]);
  118. results.push({...row,indexEnabled:enabled,ms:Date.now()-started});
  119. } finally {await tx.none('ROLLBACK TO SAVEPOINT writer_cost');}
  120. }
  121. }
  122. return results;
  123. });
  124. }
  125. export async function payrollSourceIndexMembershipAcceptance(Psql,config) {
  126. const {schema,company}=config;
  127. if(!/^speed3_[a-z0-9_]+$/.test(schema))throw Error('Private speed3_ clone schema required');
  128. const prepare=async(tx,isolation='READ COMMITTED')=>{
  129. await tx.none(`SET TRANSACTION ISOLATION LEVEL ${isolation}`);
  130. await tx.none(`SET LOCAL search_path=${schema},public`);
  131. await tx.none("SET LOCAL statement_timeout='40s';SET LOCAL lock_timeout='3s';SET LOCAL jit=off;SET LOCAL work_mem='64MB';SET LOCAL TimeZone='Etc/GMT-8';SET LOCAL extra_float_digits=1");
  132. };
  133. const check=async(tx,co)=>{
  134. const r=await tx.one(`SELECT xs_payroll_source_index_ready() ready,(SELECT md5(jsonb_agg(s)::text) FROM xs_payroll_sources($1) s) original_rows,(SELECT md5(jsonb_agg(s)::text) FROM xs_payroll_sources_indexed($1) s) indexed_rows,xs_payroll_source_version($1) original_version,xs_payroll_source_version_indexed($1) indexed_version`,[co]);
  135. if(!r.ready||r.original_rows!==r.indexed_rows||r.original_version!==r.indexed_version)throw Error('Membership parity failed: '+JSON.stringify(r));return r;
  136. };
  137. const fixture=await Psql.tx(async tx=>{
  138. await prepare(tx);
  139. const row=await tx.oneOrNone(`SELECT l."objectId" lesson,c."objectId" common,c."generalId" general,f."objectId" fact FROM "LessonRecord" l JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true JOIN "LessonRecordFact" f ON f.company=l.company AND f."legacyGeneralId"::text=to_jsonb(COALESCE(c."generalId",l."legacyGeneralId",0))#>>'{}' AND f."legacyGeneralId">0 WHERE l.company=$1 ORDER BY l."objectId" LIMIT 1`,[company]);
  140. if(!row)throw Error('No canonical-suppressed fact fixture');
  141. const next=await tx.one(`SELECT (max("generalId")+1000000)::text value FROM "CommonModel" WHERE company=$1 AND "modelId"=59`,[company]);return {...row,newGeneral:next.value};
  142. });
  143. const transactional=await Psql.tx(async tx=>{
  144. await prepare(tx,'REPEATABLE READ');
  145. const results={};
  146. await tx.none('SAVEPOINT membership');
  147. await tx.none('DELETE FROM "LessonRecord" WHERE company=$1 AND "objectId"=$2',[company,fixture.lesson]);
  148. results.deleted=await check(tx,company);
  149. const restored=await tx.one(`SELECT EXISTS(SELECT 1 FROM xs_payroll_sources_indexed($1) s WHERE s->>'lessonObjectId'=$2) value`,[company,fixture.fact]);
  150. if(!restored.value)throw Error('Fact did not reappear after canonical deletion');
  151. await tx.none('ROLLBACK TO SAVEPOINT membership');
  152. await tx.none('UPDATE "LessonRecord" SET company=$3 WHERE company=$1 AND "objectId"=$2',[company,fixture.lesson,'idx-tenant']);
  153. results.movedFrom=await check(tx,company);results.movedTo=await check(tx,'idx-tenant');
  154. await tx.none('ROLLBACK TO SAVEPOINT membership');return results;
  155. });
  156. const phantomId='ix'+Math.random().toString(36).slice(2,10);
  157. let announce,release;
  158. const firstReady=new Promise(resolve=>{announce=resolve;}),secondCommitted=new Promise(resolve=>{release=resolve;});
  159. let firstError;
  160. const first=Psql.tx(async tx=>{
  161. await prepare(tx);
  162. await tx.none('UPDATE "CommonModel" SET "generalId"=$3::numeric WHERE company=$1 AND "objectId"=$2',[company,fixture.common,fixture.newGeneral]);
  163. announce();await secondCommitted;
  164. }).catch(e=>{firstError=e;announce();throw e;});first.catch(()=>{});
  165. try {
  166. await firstReady;if(firstError){release();await first;}
  167. let beforeCommit;
  168. try {
  169. await Psql.tx(async tx=>{
  170. await prepare(tx);
  171. await tx.none(`INSERT INTO "LessonRecordFact" SELECT (jsonb_populate_record(NULL::"LessonRecordFact",to_jsonb(f)||jsonb_build_object('objectId',$3::text,'legacyGeneralId',$4::double precision))).* FROM "LessonRecordFact" f WHERE f.company=$1 AND f."objectId"=$2`,[company,fixture.fact,phantomId,fixture.newGeneral]);
  172. });
  173. beforeCommit=await Psql.tx(async tx=>{await prepare(tx,'REPEATABLE READ');return check(tx,company);});
  174. } finally {release();await first;}
  175. const afterCommit=await Psql.tx(async tx=>{
  176. await prepare(tx,'REPEATABLE READ');const parity=await check(tx,company);
  177. const hidden=await tx.one(`SELECT EXISTS(SELECT 1 FROM "PayrollSourceIndex" WHERE company=$1 AND kind='fact' AND "objectId"=$2) indexed,EXISTS(SELECT 1 FROM xs_payroll_sources_indexed($1) s WHERE s->>'lessonObjectId'=$2) visible`,[company,phantomId]);
  178. if(!hidden.indexed||hidden.visible)throw Error('Concurrent phantom fact must remain indexed but hidden by current roots');return{...parity,...hidden};
  179. });
  180. return {transactional,phantom:{beforeCommit,afterCommit}};
  181. } finally {
  182. release();
  183. await Psql.tx(async tx=>{
  184. await prepare(tx);
  185. await tx.none('DELETE FROM "LessonRecordFact" WHERE company=$1 AND "objectId"=$2',[company,phantomId]);
  186. await tx.none('UPDATE "CommonModel" SET "generalId"=$3::numeric WHERE company=$1 AND "objectId"=$2',[company,fixture.common,fixture.general]);
  187. });
  188. }
  189. }