← back to Dw Image Shrink

shrink_worker.js

117 lines

// Core worker: atomic-claim a ledger row → GET product snapshot → download the
// master → re-encode (shrink_master.py) → DELETE-first the old image → base64
// upload the smaller one → verify → record. Keeps the ORIGINAL bytes in hand and
// AUTO-RESTORES them if the new upload fails, so a product is never left
// permanently imageless. Resumable + crash-safe (FOR UPDATE SKIP LOCKED claim).
//   node shrink_worker.js [--shard k] [--allshards] [--limit N] [--wid id] [--writedelay 500] [--dry]
import fs from 'fs';
import os from 'os';
import https from 'https';
import pg from 'pg';
import { execFileSync } from 'child_process';
const DIR='/Users/macstudio3/Projects/dw-image-shrink';
const SHOP='designer-laboratory-sandbox.myshopify.com';
const ATOK=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').find(l=>l.startsWith('SHOPIFY_ADMIN_TOKEN=')).split('=').slice(1).join('=').replace(/["' ]/g,'');
const arg=(k,d)=>{const a=process.argv.find(x=>x.startsWith('--'+k+'='));return a?a.split('=').slice(1).join('='):d;};
const ALL=process.argv.includes('--allshards');
const SHARD=+arg('shard','0');
const LIMIT=+arg('limit','0')||1e9;
const WID=arg('wid','w'+process.pid);
const WRITEDELAY=+arg('writedelay','500');   // ms between Shopify WRITE calls (2/s at 500)
const DRY=process.argv.includes('--dry');
const sleep=ms=>new Promise(r=>setTimeout(r,ms));

// ---- Shopify REST with leaky-bucket backoff ----
function raw(method,path,body){return new Promise((res,rej)=>{const data=body?JSON.stringify(body):null;const req=https.request({method,host:SHOP,path,headers:{'X-Shopify-Access-Token':ATOK,'Content-Type':'application/json',...(data?{'Content-Length':Buffer.byteLength(data)}:{})}},r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>res({status:r.statusCode,retryAfter:r.headers['retry-after'],limit:r.headers['x-shopify-shop-api-call-limit']||'',json:(()=>{try{return JSON.parse(d)}catch(e){return d}})()}));});req.on('error',rej);req.setTimeout(60000,()=>{req.destroy(new Error('timeout'))});if(data)req.write(data);req.end();});}
async function api(method,path,body){for(let a=0;a<10;a++){let r;try{r=await raw(method,path,body);}catch(e){await sleep(1500);continue;}if(r.status===429){await sleep((parseFloat(r.retryAfter)||3)*1000);continue;}const [u,m]=(r.limit||'0/40').split('/').map(Number);await sleep(u>(m*0.5)?800:200);return r;}return{status:429,json:{error:'ratelimited'}};}
function download(url){return new Promise(res=>{try{const u=new URL(url);https.get({host:u.host,path:u.pathname+u.search,headers:{'User-Agent':'dw-shrink'}},r=>{if(r.statusCode!==200){r.resume();return res(null);}const ch=[];r.on('data',c=>ch.push(c));r.on('end',()=>res(Buffer.concat(ch)));r.on('error',()=>res(null));}).on('error',()=>res(null));}catch(e){res(null);}});}
function head(url){return new Promise(res=>{try{const u=new URL(url);const req=https.request({method:'HEAD',host:u.host,path:u.pathname+u.search},r=>{r.resume();res(+(r.headers['content-length']||0));});req.on('error',()=>res(0));req.setTimeout(15000,()=>{req.destroy();res(0);});req.end();}catch(e){res(0);}});}

const db=new pg.Client({host:'/tmp',database:'dw_unified'});
await db.connect();

async function claim(){
  const where = ALL ? "state='pending'" : "shard=$1 AND state='pending'";
  const params = ALL ? [] : [SHARD];
  const q=`UPDATE img_shrink_ledger SET state='claimed', worker_id=$${params.length+1}, claimed_at=now(), attempts=attempts+1, updated_at=now()
    WHERE id=(SELECT id FROM img_shrink_ledger WHERE ${where} ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`;
  const r=await db.query(q,[...params,WID]); return r.rows[0]||null;
}
async function mark(id,fields){const keys=Object.keys(fields);const set=keys.map((k,i)=>`${k}=$${i+2}`).join(',');await db.query(`UPDATE img_shrink_ledger SET ${set}, updated_at=now() WHERE id=$1`,[id,...keys.map(k=>fields[k])]);}

let done=0,skipped=0,failed=0,alerts=0,processed=0,reclaimed=0;
const tmp=os.tmpdir();
while(processed<LIMIT){
  const row=await claim(); if(!row)break; processed++;
  const pid=row.product_id, oid=Number(row.orig_image_id);
  try{
    // 1) snapshot current product images
    const pr=await api('GET',`/admin/api/2024-10/products/${pid}.json?fields=id,handle,images`);
    const imgs=(pr.json.product&&pr.json.product.images)||[];
    const target=imgs.find(im=>im.id===oid);
    if(!target){ await mark(row.id,{state:'skipped',skip_reason:'image_gone'}); skipped++; continue; }
    const origCount=imgs.length, position=target.position, alt=target.alt||null, handle=pr.json.product.handle;
    await mark(row.id,{product_snapshot:JSON.stringify(imgs),orig_position:position,orig_alt:alt});

    // 2) download original master
    const origBuf=await download(target.src);
    if(!origBuf||!origBuf.length){ await mark(row.id,{state:'failed',fail_reason:'download'}); failed++; continue; }
    const origBytes=origBuf.length;
    const fin=`${tmp}/shr_${row.id}_in`, fout=`${tmp}/shr_${row.id}_out.jpg`;
    fs.writeFileSync(fin,origBuf);

    // 3) encode
    let v; try{ v=JSON.parse(execFileSync('python3',[`${DIR}/shrink_master.py`,'--in',fin,'--out',fout,'--max-edge','2048','--quality','82'],{encoding:'utf8'})); }
    catch(e){ v={action:'skip',reason:'py_err'}; }
    if(v.action!=='encoded'){ await mark(row.id,{state:'skipped',skip_reason:v.reason||'no_encode',orig_bytes:origBytes}); skipped++; cleanup(fin,fout); continue; }
    const encBuf=fs.readFileSync(fout); const newBytesLocal=encBuf.length;
    const ext = v.reason==='PNG'?'png':'jpg';
    const filename=`${(handle||'img').slice(0,60)}-${oid}.${ext}`;

    if(DRY){ await mark(row.id,{state:'skipped',skip_reason:'dry',orig_bytes:origBytes,new_bytes:newBytesLocal}); skipped++; cleanup(fin,fout); continue; }

    // 4) DELETE-FIRST (frees storage so the smaller upload won't 413)
    await sleep(WRITEDELAY);
    const del=await api('DELETE',`/admin/api/2024-10/products/${pid}/images/${oid}.json`);
    if(del.status>=400){ await mark(row.id,{state:'failed',fail_reason:'delete_'+del.status,orig_bytes:origBytes}); failed++; cleanup(fin,fout); continue; }

    // 5) UPLOAD smaller (base64 attachment — no external host needed)
    await sleep(WRITEDELAY);
    const up=await api('POST',`/admin/api/2024-10/products/${pid}/images.json`,{image:{attachment:encBuf.toString('base64'),filename,position,...(alt?{alt}:{})}});
    if(up.status>=400||!up.json.image){
      // RESTORE original — never leave imageless
      await sleep(WRITEDELAY);
      const rs=await api('POST',`/admin/api/2024-10/products/${pid}/images.json`,{image:{attachment:origBuf.toString('base64'),filename:`restore-${oid}.jpg`,position,...(alt?{alt}:{})}});
      if(rs.status>=400||!rs.json.image){ await mark(row.id,{state:'imageless_alert',fail_reason:'upload+restore_failed',orig_bytes:origBytes}); alerts++; }
      else { await mark(row.id,{state:'failed',fail_reason:'upload_'+up.status+'_restored',orig_bytes:origBytes}); failed++; }
      cleanup(fin,fout); continue;
    }
    const ni=up.json.image;

    // 6) verify: new bytes < orig, dims ok, product image count unchanged
    const nb=await head(ni.src)||newBytesLocal;
    const pr2=await api('GET',`/admin/api/2024-10/products/${pid}.json?fields=images`);
    const count2=((pr2.json.product&&pr2.json.product.images)||[]).length;
    const ok = nb>0 && nb<origBytes && (ni.width||0)<=2048 && count2===origCount;
    if(ok){
      await mark(row.id,{state:'done',new_image_id:ni.id,new_src:ni.src,new_bytes:nb,new_width:ni.width,new_height:ni.height,orig_bytes:origBytes,fail_reason:null});
      done++; reclaimed+=(origBytes-nb);
    } else {
      // regression → restore original, remove the new one
      await sleep(WRITEDELAY);
      await api('POST',`/admin/api/2024-10/products/${pid}/images.json`,{image:{attachment:origBuf.toString('base64'),filename:`restore-${oid}.jpg`,position,...(alt?{alt}:{})}});
      await sleep(WRITEDELAY);
      await api('DELETE',`/admin/api/2024-10/products/${pid}/images/${ni.id}.json`);
      await mark(row.id,{state:'failed',fail_reason:`verify(nb=${nb},w=${ni.width},cnt=${count2}/${origCount})`,orig_bytes:origBytes});
      failed++;
    }
    cleanup(fin,fout);
  }catch(e){
    await mark(row.id,{state:'failed',fail_reason:('exc:'+String(e.message||e)).slice(0,120)}); failed++;
  }
  if(processed%25===0) console.log(`[${WID}] processed ${processed} | done ${done} skip ${skipped} fail ${failed} alert ${alerts} | reclaimed ${(reclaimed/1048576).toFixed(1)}MB`);
}
function cleanup(...f){for(const x of f){try{fs.unlinkSync(x)}catch(e){}}}
console.log(`[${WID}] FINISHED processed=${processed} done=${done} skipped=${skipped} failed=${failed} imageless_alert=${alerts} reclaimed=${(reclaimed/1048576).toFixed(1)}MB`);
await db.end();