From d7ad35a90718fdf4e8c5eae0ec26e2961ad44579 Mon Sep 17 00:00:00 2001 From: Manuel Astudillo Date: Wed, 20 Mar 2024 15:26:17 +0100 Subject: [PATCH] feat: support proxy concurrency (#18) --- .github/workflows/build-docker.yml | 4 +- README.md | 5 +- bun.lockb | Bin 222300 -> 222164 bytes package.json | 2 +- src/config.ts | 5 +- .../http/worker-http-controller.spec.ts | 31 ++- .../http/worker-http-controller.ts | 183 +++++++++++++++--- .../http/worker-job-http-controller.spec.ts | 6 +- src/e2e-test.ts | 13 +- src/proxy.spec.ts | 20 +- src/proxy.ts | 21 +- 11 files changed, 236 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 479881b..8f623f5 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -52,7 +52,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # cache-from: type=gha + # cache-to: type=gha,mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/README.md b/README.md index 5e8c432..50c6db4 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ can add jobs with any options you like and instantiate workers, also with any Bu - [x] Initial support for adding and processing jobs for any queue. - [x] Queue getters (retrieve jobs in any status from any queue). -- [ ] Support redundancy (multiple proxies running in parallel). -- [ ] Queue actions: Pause, Resume, Clean and Obliterate. +- [x] Support redundancy (multiple proxies running in parallel). - [x] Job processing actions: update progress, add logs. +- [ ] Queue actions: Pause, Resume, Clean and Obliterate. - [ ] Job actions: promote, retry, remove. - [ ] Support for adding flows. - [ ] Dynamic rate-limit. - [ ] Manually consume jobs. +- [ ] Listen to global queue events. Although the service is not yet feature complete, you are very welcome to try it out and give us feedback and report any issues you may find. diff --git a/bun.lockb b/bun.lockb index ca1df5bf024bfa2b79c04b79f3eb8a43d1008ef3..5ea7b7e9f55aa1d7525e8186e1ba2c7ddd29ab8a 100755 GIT binary patch delta 41803 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 delta 42127 zcmeIbcYIVu8#cUql0cRc2oO>TgwR_;2&6z@si6~k36MZ~A&nF|B#5AhG>)oT4gSRaOtm^ET5TJ1=GK<%L$?wlKOGy?w8lH)w|Y-J@_XBLzn)$`_(W-jht1|= zvz6r*WfUZ(Oe&mG$!4nw-QCk>YXkXVWhHk&hC-jC^`Vd<&>KOvg)|@=LMEa-2jquU zZMGVaA=PX)FUXwy)TE+Jn{A}#1FG9>RcyA>vWZC0A-OOqC9TM2`#=$8_2EUwe7um) zO)Jbu3rC^gQFd8YQAkEkezI*WYC%C|f1@HcJUywXc(ToAD@-dX%1WPZ`viP#*f)Yi zma^|5*{~mEely)3URuA7qNcZt&V#4TTkC&8vgjTN5ak!peIYMGvI#awZ^)Y{pGBq> zr6lDfrKY9iWR+y0oVv)&rpH4v|7J+EzU(F>%PY#t$VK2XGnC>F^RHvXrG27Uma*q)kf+PzC=6BP&SH%1aF?DX<0CQ+jqiv(RW(x-ihj zrk15-Cgn^*aY6M}HJM38A?Z0u8UNJ2{pc5q-3rOjt$$@MHc><1gA=f}b8_OCsR+DiRB*$_gBm-L)k_~ti`8X%g z@cgM5svAvI#qoLo`rB3cyHGCrSk?&A%T{VDD+nn}OD#!BTZ)WqX_Hydo(&ECTDQRd!ab{tDNk(S)wBocp1jhDt zm>Pm@?Nq)sS}uj;+?WZ;xs_FvR9Kia-IkS?nO2xpT$B$x8m}xmhV8>3NUAPXB_W(&EgJNoms;Xv1tsMx+3e6}c)-gU*7cW@e>ep|WKa zp(@DKjw%9ComBpUBvoEo-jtA(ESv2o*f~jCqT%#+-333#sJq~UQY1JVvx`EeC*|hY zFcsu5FNK{gblqOLT~q~sL9&4hz*7dGU{-L_srX}%tjKj+->hY`wS+#mo3fvTl#wgU z&qbhZrLG>PgQ$m$t|7=x%byC~_I-Edu)c??=u_w%3OyHWp*>YY25A3KNOovuFEs>{ zA(_8ptjaevzc6)bVNyX`=&+ZTHPb|HRvt#DsJJEyXaI+Id#j35^NT|Y3iAuFrspST z(9A``+qqS3<>H- zF*e&}=vAQ4f@B4*1vC{p7udoSna!3yP!%)|k|ED4$d$wsgVosn0eQbUvjBFi)n&;g zSvkc%S$RdXVc=L!g2Zr@B@H%*d4`q-#3}!jOsr>x_g3bjqC(kF2CN_n8dh3XoIOJ2 zABJLTpcn)#B_t<5qbqcFc+*H_Uj@nPKG0JSTYG*nCNp-NqT>9*v=>Lq^{TXN0TR6X zrYJ+%{YYI;l%FyQ0kqwQ&MEj4B=gP3aFjBUU6h;$-WH>Yk=ij9k`;8) z1-fUbxw=aaSqsRz;D3bOAJP{(D-6_A>?VeSbL6i~6;T`P=^R#rr8O}p*lB8`Tvol8e9eW*zk{G z$A(=Ni0*OdaPwfQ+H5aFXT2For6n1Nf-OB?g=8EgGj2x)U&uJ<3}q6gc{0}g)v&YR zP~8)6NalYlPT4m?GXF|QM)na%*+Fej$qLEJLuEyp&n-m{WQfsEW^4h;2zW!X0e3KP zwIRQSWKXX`auV4P4OWzrn;KG-nU(tArd{54&T;pdPnI1)S?j)gNNE49!zXr}6U77sib zY15j5=a?gaxigXqaR+9x7+Yo7Yjb73gO;!XQ&Xm9r50z}Qq$6tN^**eQgX1LBI8fs z8RDeEluXoVn+@Fyx}RQ&Ye3RJy$HuqoFP0)RfDpNaGcG`NxO(}G9ruM%LbPe=7ePD zXXO=yfGswcRIgQI)#nyZ3%8nOnmJ`c&DZ-GQx%N~bhUwCIHqb)@_@(qP;+dS0- z9Hz0d*=&oUGuYS%=cORp@ai6C=W z&1OAZXKdGdjBLc1O?AE3D8N3Hl2n`qi9Np9RuH0g2-g`uzP9;d%>_nqJ+p^*Z=+@H z{f~G*Z8!JU^RMyglrHN##=Y0B>r+i<&GQa03w*-NmxIRa53AkH*uT-IW~Kda*1O?m z`ZfH0zh7{*%4U-`9UKciY_>4e?l6PbMVqTzbhUqHUchS?)4yd``-^6L%dUIQn1htc5Ii*69ZG@BFdlsx084q<` z?HA?v3R-VyhMCkN$`OORuNO4Cnbb1MqZC@CS-*FT;{a0haWjLPMmd^bzIBG?Ze9(I zGP0_hYq~fcPr}$iW=UER?WuEAmZi4FEyhwkq&5v2%c^V!M@1RWdYKoOIvroah;tya z1UE+oxM3opp#n596&kBE%&RS;JT^k>Y9@@0F@CIJ1}t|vnqty2v)#PfG0HIxTCB=E zE6TA8S~tmU8WHUYguA9sh*hYd2a>zDx{PGe0Sb8LvyQ3<9BaHq$2;38!`ZE$O`ZHBC;L~E+_ z{m|G$#eD}YQkKqM1P7}AqIA?e92z;5b3L>nDkrAkwLmiu~lF}=1mHpg~yI&Q+K@6(7VM^qElR5$Y~ z0zb8h84%-i9EOp1pt_ZBLt{G;J;btWQ{}F1$obG>z*V8U<7H^$q~E62QI2}eTuT6B zG6CAXSPEz?Q$_XywBFF4<1;v$cm`O&Pk(*5(=*x5LO=tFl}}K7y7GP2I!Y+o-TAtwd`c^4{Ox z#=O|m>G&N+&M~y5NtB~Uh?=RGab2Pu)1WDibNV1OMiBQY#`gCRGa%OK=oqSMM?jEe z5;RW3N^)>^LgP>&OVCTh~e!+$PE~6B=W!;&}qv-4XM!<3{dfUTPX+ zObau|_Hlabg&~^Dp5r&9*k^aSw6$z!2K0404z_cR{?(Szp7#_iI8J0{N7NEM3mR`g zHQ7!>Q#URrje+>8fx?PxbZT#2?B{gMfUz$euuP+{Q_whGxQj5B9+L83N#*0xM$ zpPbbmRie;M^U{{io=B(y=rRWy`y_+xaS&QJ^HTE|$FJq>j!Iv3ZDX%P7aOyBhs&LgP)RYUqXHS*B|G0%#c97BL=2 zk%}Blrle?3C~OmIL~uP&or=Mxwb7nhF}M~xrbA=XFnR+e zbuT*pU0rjH3&v<@96Y%&JGMg0f>uo}xd=CVq!yv~p)vj{;&rh}t2nZeeW9_pY6*N0 zS|T)6=I_ujvp987UmG+8Qy(>OswP0YH>SIwsrX|NGJfn~t{Lt0=-v~NGVAw^aZE*u z-L=bme;+hfqVCKZn8wM_Fvw$~J)v;Y$&-QcZ7(w*-f6_enq%Xgj)geDFw}C&8^>eK zi}6m6+c0)A*SCl*XzB~?1HIF`L zp`qhtRpmU3#@)$iVcS6rrb0tkbtKO~;|QqPU%#JhN?43Bx}O=4;B-6&0~@TueiEAQ z8qQ43`kNOMoW}b8W3k9Gof#1`v5awg41yc z`Y@UA>W*mlf%wpdM?S}Nq*yfWjLFfSP&o8hL$K-w4^p4pR57_b>>aFjVs-HHK-+ar zaD$<-qpCA=sqv<9>_=)W>}pC>AFBGKN{EG~rZEFo3JsNWicgF7gu;1clXIssTFnls z)w?S+Mqi#yJ&K@3oAsN=7(0iVYf_w!&u9QGYxJl$9Iccmrg)_84ccO8m@T+z*|T%d z+CjtK9g3xPgepO9TppdEg~<|1kYbP36goA+yqM;6)EcQWt6}O7jj@!+43E9gdYcIm zSkaJTSFvzn^$8oLCapS*tQ%#H&2V}+N3$cB5@H-Dky7<=(s+(3KfQWDBZng`xH4$0 z4q<8@<#85Tv|ME>#k=~-Tz#Nn=*z0h1?p31oP=_d@o0jVRii*bxbDq5A?FUnXt zmb<-iYAn|;htD|Ieu4OS#8Wdbb&PTBL<(ys>JLtgGOmxKk4NwExNA5f9t)5{#(wA$ zQtZE4zuXgCGoNe02xwUF%WBEk?109#K*jSe@j5ilIW?&rDT=Gab=#v4 zw7&9=S*q>W1+ZPhLuCkmuOEY zbWsqq4wdhP#wg3q zczg~mPL4%*j;q0J)GTNWhg!>CgT|d48$If(lB+m1!G}TP#-S$h^UyF<3=YEBJWsVz zacR(4v)VvUL%X}-II88V>5lsPMcebui?f`Lplv>x!oYK?X`EmTuU&GRH^Y^iGZ zCTPrwX@*#Q22GuDz5JbNP?#Pc#iK~?1~e6vj7@LR!x?7psBtEkBW9L;gBSR z#WF8Z*lK$oO!I888p;5vICsE;ck5 z8cSAzIRcH<%em@Ndm6V=E_oA>8VkD&x#I&`X2->RQciE)67}s*oU2tlQ4FI5xvAQz2X!KL@tv%mn!^s6Pkdt=1z&1TZ6Qq;st1X(`Sxt{!pg3Wt+4e&e$~($(BhRh zI??YDrD0QO8fAahydb*Tzcu|=bv5cQGRLlR+LO%HtGe1(m>2N+k?Fs>s}cC9Id-+v zKFVCZx~pT&qpB_PhV=LdntV#Pf7P1Ke*9p~)(YTtKWT@(HBcGAz{%_Xi)1AXhSk_OX zH^8gBq`i-{-e1-SmkKa4*em5#Ub?}8olKUG9ZFvRhYb8D0(Lhu_c`BEHUq1$yeL`h z41n#Q1-Juc0IxFnswDXbv`)!#=4rjWr2RpF`Ef_ci;{NC_EMR^{FN6a3CvG#I0G%&=A)DB05W0PPzAUK{1Bl8nex z0P{Vq5QQPk) z8Of*RlFbA%vfypnal3Y;WO9er%S)PeYC9#9yR?2k$%s6od6z78B`9kl!*NKq>_3on zIH?^dnLMR+N;dcmB=fxu$(i?&wqJtebw5e!GG6dk*Y*isImVwUgY9!lGvSP{9GWko z@uI8-`J>MDla@bg`3oeQ^BW|u@{*?C@xrNkTj%>*OFNx#QL@{WA=#ZO2BJa7syYLu zo0)Lf*UUNWSxRF~yfBBimbD;Rg)bzF2!O<&Es!r-GTA`ubmW{P+N+T{x z2CAij=eM-A)&wPqwvb#&BemWM5`VVNTJHwQin?prL(86!Y7-SXgg1Bri%&$U;cgQ>5+xZzNqxv>zqgH3O2SS$JW&vkfd8 ztY|*1TCyPvp$9|m(DwgD(r=H>UtY4JXJ99_PxJRA<|Z8vfS`OqXP{*AB`uFYvY?}o zyg5!lIw0SHSgex~zLGWmtp zDH+LYS}!j-hi_;*C6hmDy_5-W7~GgkOE$<|>*XcK(4p=Bi{yP+L+AeoSt>KI!kRiG zC6l%B!Vm>$dwI$H^|YOm$p(0#JxJTjOAc*g*xBi(kj&CT=EL}_gp$nIMrRC#WJn`r zMyq5&(Xg|^j#_ro`R*q9U#aFPnS4O&l&oig z)+wn!;wJkq6VhR^&Tv1;ik51gl95@a<#H__(>x_BSgGv-lGGZ$SY+*r287bQ^op*Xbz{hTW_=e{H7fHX9$j^a)OZ(lET~&qWHSxblX8jN^Y}iH9`?W>?K4y@E(i`#MRbFzi z`bg{j<-d;^)Z+%usYe0E>)*!=|2}5GG`I@zT<|r(i;~mhe|fyX{LIHC>N|jb|2}3w z5dMA4AkPQ?K4vKYxPgmr`KJm{ZT~)IU=*-&U|2}4rj~%#`{QH>U-^UD% z^*f+{A2a;>nBm^zhJPP3{QvZr;llaxcAFXb{%E`|k*{CMSLX+`tdy_UoL#o?_E=$8~xTB-)y&FG{aTVOyy!HD)6zlxd*G#x& zp_-c8Z;`qVs`?)ms<|2e2dNF8`HK44*6Lc?M0U2UF79(*ai#*>+9m=fN%jud*^{ik zAvSSRv3tJo6(KoRwyjOf$dSH@U;2tm71(w*(JEK6USA;yxpqs7+nZNEVT+G`snG9y%>!1dt7-q7)MZd?+OUmmr{@)LBp?uUV*Z=B8`(n)3_B>$-(XFo#|z75Xd2%Suf!?LUyZ z3@Yshi&v7lPf|;M!+71WP$_284SG3$$9P$(G}HSgsq3IhZd$xD%;S>Ua0}!0qlL;c zC;v#VxIZvnR%((N{1d4=pyvN%@ya#NNoo(i+!^t+g~~VQ{!FjLzc5}_YO>kx7gAog zF{buo+Zr+sCSx_UbYt~G2 zA6K6hbLsGGR^!|(7WpX8dS^Vaj$0fJK0N3=kg_~zizD+-; z8@TgUZoV0Ohun2?^Y2*v9x~5KZi73xh`%k|LUZol^o#QV_l1>Pw4$Az+#PT$R#=y@ z#Vf8#ZcinWXR z&tdlymE7%J%kLpV40V@mgDUVn;BNI@E$kkWJxeyt!^*A|`xHB;Dp;RNR(8Eesw90w ztATyJ0{evUt}NNhWJ@YreK(2Yid|A2Y*SAw`=prcDSe$@V9!@zw}@beWUrH*@38uA z73UPYp$6E9Dpq#8m|I2q#?=J-MFn=JXjfITcgU`&YW3YMt}1qqH`v%}R`waOyqfe) ztOfR$3hX}7y}D$*YJ=TU-Riqv+*IsQvLn5$>;bXKOZw*30qar2${rF!Ye=?%57+}0 z*cXJorex2OO{;13eM#(7?3}t_eY~yg5s~CAeM5c0zFvVnCcJA&_A=R$T2|lV;<#d$ z_>x5RQ^>6=&|>@OAAbE3PSWW5@I-Qs8UeOKI6 z>`}5K{jKbIvB_Wh<^_TE2(Yprh@k5px?#-?(OAzo@`|C)x!|_72$YX#P$sg?ar z3~ege2Ccy!sKDM5_GXejOE#^U)%Q=aPqA~_fc0r^Wp9h5=F&Gb1nlb-*uRB$3&~z4 zThhXMnz!4UrKFv0L7%Tcd)P&zR+7F>dVVXbcV)YHN6{O? zKu5H;(hj?LptbalYX|y^3UpPw2yY|lJET{%v3ggxi%%83CmeKah?TBk7mtNV@5J_? ze*s;7xAYd>LuFLFI)L30YGrGSn~FV3c4S*C>mxR`mA-iqU_HXDtgjdvCfNp&U=LJa z{e@kfE6$QlYiISXC-y0JP83+5a4TD1B!$b8L!-gIUV#k~-t8rOnQTdWt8cJ4uGl3V z!8Yw+WgCmh9i*?b6WH?=*rp;lLbBJ%&X2JAHW%j-?+|Tzo@{r z7VV-Wdxz|bD64OXxT@GaUBJdhTiLc^d9?IRbb|e*0^3e>?h2Ya9b+fmp%OZF_;w9Z!F7_m>Wb9#XF!2^Wy zbFz!ba!TLOo?y>7t!!7}-&L}g$kradxz}$o>t#M;v2>8=?ivH zFDpA(tnMX!6Z?U^U4b1c`o&7tt3TM?u~y&V;+A5Ml1=DsWk-tby`^v70I=2jSlQ7c zzK>)Z3O;-V&@D3Tfd)`O%PfAq;F^(*fSN_3Btd>WG|DQ z-QVh)Bu*%H$zZS{1FUR{m_9)II){M0RDn$sEeA^WI@!eot-cxJf?_ue1>0qimCX_h z2T9+!VPLOSU?+)=agx15c72@HH&=Y4*geC+4jOD_^Tq1H(l>Dg*xMD@$)ev7$$E_h zyL*V$w@BPl>`}4_L#=Fy*gjPH=8XbdeVCPg=4s6&yvj@ZuOlh zUQq0uF<|SDu(Gp7)(Ghv8V~kN1-4Z9kCg0Xva?58edmf3id`}mY{)1pJ5Nj>C4HUa zz+S4r&KE65OZGb1#iOmh4+%VE7%$m7WY@=A zeHV*w6uT!8?4Yq$cBxoBR{ADR0DHRvyIk}eCt0tFV0VwR`kLaFVvmwd7;j})i0$L0 zZ(b7E>Iqg>i1-A_Hb@40qyoEII1(j$mTYdK)pxCUL9ugEz}BB&W!H=VL&qGT_VojuX&yGfi-?2J=4k_67iXmZIBE0NCox<;mDHg zS+coVR^OMzgI%7Abp*a!QQUGzApMrmh5%1yC++H|08ZGc0(c9ghDHOLToRTzHvohs~1_>QzE`d zvUkWHslc8Qj$+B~DF&NcZ1sIhyr9^`60r44tn4|FRU&=8rhq+Dfqhr_Pm%0Vva_dH zeb0*%ip`q}He{-m{Xk5gDt#MF1AD0gdqK3ECfT#o?D&L6G@CBP93|dYB6Nl{E>PmK z5|J~dafuRND&d?ZjVqP7u0-$I(zrp1UzLcPBaKfhaYu4!XZcyS^CE^xJZ$E0zk64#aJZA#+?C4N;R z?r~{+T8TSKj9MX$dz7fOQfi4x98|(fNMpJZN0q3%N*eQ&cteQ>tL>vr?0-P2=2b6wUcvtg4uvb=rx7CFAtQ1&9EtVTMGcOzM)2;cC=_Ylfv zcN7h_+An(S>x?f%%8~_2PGugC^loU7_-4C3h_=qO8J~6+E4SO@jL>f4%kB1N!LA=t z|G1a>C0hO~&XSBA{P2HvQIA-Bt?X`V{p>72)Y)a9R^3&5PyDbzHPQV!yD8f2MlT|U zp;h#FtGes=!uOBB&+yWeA>GUNAlqa3B@CHO_Gp$YxQgrNv!_pBkIphcVTSZ+_?+Fd zZonk<8@+6x>t~`%CJER#y9-h^$J6yg&SJxMdk=PhGmEN{RG6G!h@XQW zStRlfB5ZB(Meutq36%9!$M5;4p%z=f-16j8vYCzo`4hTng_{rUJ7Pa3ZHFE{W*=E( zj~181jsB%y;iF{!!zF$=&x<-6P4Hj0@%jcf{!2iDUt8hzz4qgG2>8B{*EQIvv#im~ zjO(V($FkzJjbHp@`S>@yrM7X}==$~_zi;y!Smd#}z6r=m|DXr1-*kceT1%8Jh~Ee# z&2Lmh>+~Ox{71rUToq5g+lvizS^M#hIZFOM7AAd&L_7NJ*VW_L~`6Im@HVy;7)X07Y0Hr&$(N7z(B-qk) zkNsg|G7uPr1RKV0K+>6CPw1)h)z`KLuqA05zY)oNLBP)dhlbyZq^%+F3jmv!t)Vss z!!S|oi!f@J&SoZDjWyL6>BqIL32gYYH38OWTQi-nDQt$eHP^Ogu(`v=(P*J<&5`~a z;Ar3*n6h6jkY+nE7^Su_ov|ehY$%MjcG`yJ?e32Oglk)C*q#A+wTBIV{C@-4o}~d- zgw7X&G;U%3;J5r{Cu0=~Ow+cGG9TtdTNtJTy!h2mb}9@w2oDZhjJCByItwk?y&JI>AVIjTdA!F6ixJ;Yg=!mUj>+t-@(P7{O->}NY05-I$vL;7x4>mG>+EB zen`&%I48zvTYscy0=(k2Z2;0m7-L>zwT)}i6xcXV_@!TVlxxydZ5t08UE%u^vF-<#jomvamt>VMs3q`T*HF-*BYo0qoEu*zhOUxKgx$ zjmp*eN=HH|)5bg)*+MRST)x@T$vWd`q`7+2R;X=bkml-5TamWKBhA&Dwqk7?i*zMz z3Rf3g55^jzVTxNr5fft!6{}*5!1{?S6PO5$ z0OEkbzz|@lI2VJ(I~>XOKnI`;&>i>@1^xv54EzFcFaHzx3%CvZ4KUbtAO?N!3~n_w)^8|VY{1^NN~fdK$FL=F){ z%pqXNGl4818yE?m=bT^p0~-9!_I2O~pdpOGKqH_r5QNMh0$ftLgw}v#ZJ-X|1Jnh4 z0YAVWXazWcsz7zX3kXKJ?vNfpWxx|~1Nhq*qwJNj=pZph-0WgB7Jg16&|?zx9MQ#T zv~^5Fayl?WlsJv{r3;Z<1Uw2X29^Lzf#twsfC+HZUIDBG1h5KN4RGV;&vc9cOmt-h zuoCDGJr?K#bVRdiLHYr9fE&9TU<0o6CcTFA1>hp^F>ndE4D1G;0rmmhfR_Q&f!+Z3 zya<3>8n-a+mE6$l0Pg`OQRyk*H1INT7}yIu0b~GwAkUw`KsVf^1xQQ=CILABe+s1o zz@J`e1~dU;U=M<0AW$C&0PssMwnZ3-#lR9^DX<(cffc|i;7Q;qU<dVoK%!i{+~FaT+8$lQRr z;Wh_a0D(Y#pej%Ws0=uOZ{YJSa0NI9ybK%%UI7kq{GUhS1>i;CIbbHx4d@Oyf%ZUa zfCsSH(i4|5LC%1O!6o8;pmLcM*BrEehLP2fl1XW$p$SKv3`5#${P^a6SV z(eSGa>BTp0EXW=B8yWvwAs>O?1xy8|0p~RiqA(ubvw=wf?SBEuNZ*3|9pGE<>%b*o z6R;Ur2UO$z?+N_3id}`gkAjW@uL1`Eo+=&z76A_fGXRz~1r_sTI0Kjo6aYEEZSdm} z?aRnh1M)UxsSgr7C_D{?hqUdG>yU8>yn;(#H*a9|`b0vH912I2uO5tQSA@jxOl z0bt{&0jWSrDPDN%<^bt50BJxbkfHS~NH%Jcmf4VuR3X5^a)HS}9#8<}>olcnK2AY; zB~Sz4UCFsv29)v+VW`=VIa+d}KM2eN<^m4@j1pJL6~N=bQeX+dJB^cl5pcgDcm#G= zgcw%5H6TnvBX1)K@?mt@qF7u0l3?Lmy0vZFUz*7JZ#WmnF8}dnD0n$%E@`%_M;M={Su$2Jok(NhB zNFEFMmciss`xp|-fn~r_U=A<~mOh+NLnXi+FaS4zlhXsTGJrpuH(qK2 zH2|K*ssV1W^DIK?O0(fSTd?Q#fB+y9D4m2CS7;edhK-rolL-KO!=A9!&474dG|&KG zquJnKpdru*7zJ>Mg0xUdz z2<8m~*z*t|6le>OD1sh5&u0~qr007J>K=XmqBp~Rn@ zh*uyPTF!JjGavpWCL+O#vmsr#YckTDyj&&IAXzzYN7k7FpltO^z#IZ*Ox2PzlbP87 zS8H7j;bdiWI5BC<0ysbLC(Fn|f{i0efI=W2$ODRjB7nBZz;j&xIc^-Y0vMQf&2}~- z319@5q! zpL_ztVPM59%+-6BWaC}EWHi`QS7R74u5hkKuyW>I2`~aHfX6xhEZh|$&VyL3vuCS; zH30c_01M;-gg<%esibWjzKuw40yYD#A!V4^u=RkRaFzxQ<@oOaxm|;^^BB@+fiu8S zU?;%ckhj}mqz?hE+jJk&+#UBq?g4fK+zFq7JO~^Bo&)v+&jK$2!Qfwnd;xf#AxVSb z2;?ikX@Ctl1)K!l1YQHk9S2?pUe)@4AYTXG08Rj|e9XiAl=OF{DVfJr2lXsg_AMH4 z6>uFuc=%wi8UgIhd&u)HKzSbc6o>*`5xIzTL!JKuIi!yOyMe3FuK+KI<--i$raVl! z+_%Bp5IVDeiS!r1cSx@gXNDO;u}MgN4O2AaH;_Ce@Tmo}eh=`#!1sv_A#Xtc0k{TS z2Y5u<3~UnZh8u0W@DLge^UuI9fDLZHL;eO#h585N8<4jk{|5d7{seMiD-`R88;u=> zNWx;v6(@!pgNLp_G8m-<0UTg{BFP5{Nw7DBWT;+8IubYqxDJRMXWE(|4?9AxG5zo_ zDvgw2js!=66#k@SRYOK153X(E+6bfhA-|EvTDx2O&~R}*)d)QF&M0G}-5BX1=4>|n z#k-^M61Ls&a}RHq=pkyZF?{Up#Uo>kKzoMRI>yMbPZ2fZ4gUzEvJ76?A1nLDc)#}a ztA;%yG%S>((FjaiEExM=%Di&mrA^Pf+uMT)2QyS8z_s2SWN3p7!R?kUe8U(q=U#># zVmUI{KNLG50{Q=aWj_DCb#}k@?41Yh<@>OzX!DTa7Z_3vdkS)l9{S#(gda~_HSCG) zP*)hnv)alhk>PJ-sEG{6+N`}8Gt#fW%n%;h9%r8FqQjF$xIIAF#~XEnI^alFn3R%M zgqdIG!%8o{`c)9}>V{9OE(%f&zsL`&+;jf`%c!3$V84PRM!rphgPs5?qkA7 zoT;?qbubJEn0zmB6uzSDNh81*TSKfvvy4w_h=WfW0|S51&0CcE_`bszPkdt7!{HTy zP;IO!8a!qA`F)SpI<<63_I82=H(xX zd~X~A6DfZ6wy8ZkJGHiWJ0G1rJ`7=b+yHi@o3}L+TzXucxTTxd@5spV&$ui ze~xRE88>T-h6%`Ft0QJ*Kzi2^FF%HyISEEUWc@nwNL|(?l&_@t@80 z|9}5Jb?nGXonP(+CR(-Ni#p;k3;e5&Xt)8ghmW{HT@~!Z|0S%8s_QG9i5Ps>|2#gf z?JJx8Prh0Qg>_Jcx&C+aw{z@!3mWyhC>a@3RfbOxireg~0-rp&NtbR_Yech^a7WN? z`ihqmjk3TkergW&d&Fl+wFi!2hjmr(o1Yjz0jWfcNR^WYl(k`?k#}z#ZOG0gpfW$WMY)w8p>Q=4=1i&EsT`WJt3cs{dnm{{w$txjg;~ zdk_^Gj?T89*-*rxxNXZRDd=RQ z#w%x`lZe8?#$qEas~U^zRz_LEjT=owqg1pbsJZx%vUPJ&Ee#&g%|+f+$R5o_H|hhM zi?M0OKm(EAmWF&=TZoToKh#3}3O(|83)TLF1AY^a6b$!5tJGa@b8jjC1`!6oC z71rQnF0`?)rlg!{{!<}5F|_PhSdcKXFjSe?d$979?fmJt&0*(%cvF{+9I;_yNEX63 zJWOOGr;!;ZHe{jSUx$hBspq#74YQ%oY$x)vamO!3d#a+O;E&oSHct%x%COIgkn4(V z4H&lKrFXte-d6P7Ltxa3W82nF9ENY;Klq{*ue298n8WqIc0YB^V@&sE$0E^A4jxuL zw5Q1=4DTS^me?T5eopYO{M()RFY2Da`WC?3c=FSiY*U*1b0g9NpB5<|orKcwvzqe` zL%Rzi#mmTH%#IYH#mKn;Ckobb%JZ9M{o_uazE{>`k-{qn5#4|sjKk4kS+5#*-d!f! zA!EKhQjE{Rj7jY%Udut`qB@CMx$yX@liDH96nBiT8}sN$q$UZZ#agVnkRTa^!YVb7yDpn9YC8|C`Ib zp4!+y=Hy}A{z481%J{BYL;tD|Gz~`%-mEC&m{aw{b79ZQpFFv_3z!JZT`(t|;xv5k zv;1)%M+RHXZY#011%`H04V<%MZ}6hw5l^BBy{irACXP-<*V4O*xO_D9K0}~$X4V#0 zwxX=LD2stz^xWq=o~<)^=)JP^M(Wxcg;8L1Fiv(8(FKSV=0{Ee+&}HER?&i$HNLKv z^WYA+w+n60#r*s3Vq*cuV|@>C9XXBAp2Bl7?y9Js%42db(V0!1*-MO?jPX-V4-agH z_fL#m!Uq@g*Y&bbM~UH~ksPh2gTx)s9n-QFT^CuC3`QCx_PRQTn)nqO1f1lRi+KEkRjB2C3Op@5l#1 zN6Tip-K#BkkofCy1bFr!5jX{M(I7Er1>}lBVhHrWo$zEI`#$?hX21Y!7wW``I*ttz z-Ab_);a-1lkhp>qJpY%|Uk(z_Pcha6{scd^?Zex*-h3&1Ndo-zKu6uhx^=C zJq|B)pRfCg&P~)zy?)O`rESkY!Pa2nprP4u!fQHwi*$}_d%q4DzA?P*z0zlcsg8OR zJ3gC`KmR)h*HTh}zi@cf}0+_r{i za?tYq-=ZK)6&{&3M6O&2|H#<}&IECXHqJH-dtmkm)xXwD*V=YBTJfsfQsp|Jrj1|Z zs*!45_GmXF#c%L~!7^4d$0jf}(1=da*$uk7?YD!`%lf%dqFO1U_wgwCK%y+B`)6M* z+qmIRK76pqSca0XI&?CRZEyn5h=7CAaxDfhP-u-&Mvxm92U4*IcZwo#KVx1L%Njjy@JI(xC@F5wBK_ zzdKBh>$~&Ynkc@5I@0Wq7tb-rVdU^a(^kBAWZ=O-2T$I5^E??Zob%BMJ;eh*L{843 zLXSybe%)=^@5mX3%>wtX3d?iG`f;MtgT}zn&&R2pC!TS>m^z|!L!A?MTc`1AExlE} z(E*=s8*do)R^vt4gGR%<>zx`3Kk@z|BS2h#(C84jC_#nj%f?ZcpFI2)cVIo163Bvm_0t5(j4#x>qU=@?JuI5j~mLm79MiDL^4pDOPmJKnUFU7sK>FECm)iJqu#qui{# ztb(MXqM}O6W~Z#`gq_2cbKXQT5sy5EEJMzk@Z2{2PJa81^}4&u+ln>a=WQsDtB7*@ zB<1t_A2Tyw8T0rS+-|Vo4QUw;>Zcz@2gg9?sHeui`pSkH8$AqrPgs0l!TJ3!K5Cf( zol~}5m7CwS>Rrd(u&2Yq9SBGBFqU!O`sF>QYvH@>Imj6~Jz3Qj|3>7nZ~Nck^w+D; zecrKm#JYq&xLTT~52Hiotk22*&Pqs)&9Bx6}MI zkwXc7qn4~oeE*0M5a^zvBDN-F#O*tKz8I_?=3oZc^bWyy!2fjjx^7gx*cm@&h@*>8 zlS|iIiMG2opZ|8d{*@_~Jc<(38dgtSdercBJ;D)Qi*cUR3pr1RiHi}B`))>ZmaEqI zxzss&~|m4@bSQy>i~)VOO1nuUe-mxGOTs3_v#fwB~q6fZ7WA`J<@Buc$?YPh8k}C zks}%|$JnaH%3lm$ZnO^!%u{`OG&{uCT=N7T$hlTOR|vEWT#cNZUK#JSJ^1;2?-JzH z%gOdUabr0e|8kya@)+)IXTHkm`X<$f)gRbsE=qX^9$ebdKIP$OjLR2~;<1*YHb~b~ zF4se<$aECM1-E*_uPusG&n4>uaNhqvZ@pc}%iGtrQz2sGO%$!RIX}*;WhQni#rwIW zX!STkuq72vR)KwgEhONL9`seekKEM9cPK-NTooszPkla)x zPV=yPxJZ1wnqwpU4b8cS-JB`W)2bhFjUy!mC9hb0tRQ+aj?JlhSoU zDSFX&FM3{vyPlCx3>MWG#^X~&v(@mxLyEDKY7+R1t*edpI@aNO zpOR;pR%?(+&k!+X4Q7UGy12&QB~_H=V#8CbXtNe)JKgFad??TfUE_PE+&9DB5A{1! z?_SqjC44?z+*ymo;vY_G+ySPG@O5aAYjr`i%hq9$=A)r?=$$M2t~L&uDFW9cKkPWf z4dfwh1N8s$EKe4mj`gS}$^1p45YFm6Nb;9h;1M`QYbiKpol!-Q*&^Y`{1HB0lI#ao-Ts77(SxxDZ|}fENW~u{0&5ZBR+VEI5b}^2AiJke&&guXd1wFYkc= z`p`0P2k!a}kz&XWv;~hSvv=U`uh_P4cNh=or&wR(lcz&tb{W}rK3aQiHx}vt^8v5i ztFc*Z19(#2-2VNzC$`**LYr{-<~vFFw%LOxEGO{UMP20hb9kcvh0Bj{9oFxl@Ilw7 zd(ajj&D!LLDt?josVqDc4;Sz$wZA$ol>3ZD zKXgF#aeEOO9Go)uqB(0CiR=5IAo; z%vf#z-Hffp(EY{{e$rU;IplhNuITj~N^^axx8pf%f*`Muv_Agz<4v4=09C<$@&I@Y z2tEpY*lG=Hx^?CM(qV)?#>~M6o!e`ORS$Uz^*vwn+yZBMF59}-6 z#;K_#Mx@EFb$n7HKfitpI^PN||LBD^f$J95afi|r`kVG=*3^G&^U9Xu_zOnY_;75- z+_WByjyP?f=5;5pAQy*u*arQ5wZn|f7d!^3sjxBT`i;SzR{fe+FirCA4}atTY^xP5 zYHPmRsfIIslG{x2a;qff^l=Ll^J3ikyXR)56pB3`8Nr7h`N&vm zKlDwE+Zp3f^EZs2?T4DaX{6Uax^4A5w5N|-Mm@)yDQ>cfr}43u+rj?_jZbU^ diff --git a/package.json b/package.json index e0414da..a1ef9a9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@sinclair/typebox": "^0.31.17", - "bullmq": "^5.3.2", + "bullmq": "^5.4.3", "chalk": "^5.3.0", "ioredis": "^5.3.2", "semver": "^7.6.0" diff --git a/src/config.ts b/src/config.ts index e791e79..0374d98 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,5 +15,8 @@ export const config = { debugEnabled: process.env.DEBUG === "true", minQueueNameLength: process.env.MIN_QUEUE_NAME_LENGTH ? parseInt(process.env.MIN_QUEUE_NAME_LENGTH) : 3, maxQueueNameLength: process.env.MAX_QUEUE_NAME_LENGTH ? parseInt(process.env.MAX_QUEUE_NAME_LENGTH) : 100, - workerMetadataKey: process.env.WORKER_METADATA_KEY || "bullmq-proxy:workers", + workerMetadataKey: process.env.WORKER_METADATA_KEY || "bmqp:w:meta", + workerMetadataStream: process.env.WORKER_METADATA_KEY || "bmpq:w:stream", + maxLenWorkerMetadataStream: + process.env.MAX_LEN_WORKER_METADATA_STREAM ? parseInt(process.env.MAX_LEN_WORKER_METADATA_STREAM) : 100, } diff --git a/src/controllers/http/worker-http-controller.spec.ts b/src/controllers/http/worker-http-controller.spec.ts index f3923d7..290fbdd 100644 --- a/src/controllers/http/worker-http-controller.spec.ts +++ b/src/controllers/http/worker-http-controller.spec.ts @@ -1,6 +1,7 @@ import { Redis } from 'ioredis'; import { describe, it, jest, mock, expect, beforeAll, afterAll } from "bun:test"; import { WorkerHttpController } from './worker-http-controller'; +import { config } from '../../config'; const fakeAddValidReq = { json: () => Promise.resolve({ @@ -16,7 +17,6 @@ const fakeAddValidReq = { describe('WorkerHttpController.init', () => { it('should initialize workers from Redis metadata', async () => { - await expect(WorkerHttpController.init(new Redis(), new Redis({ maxRetriesPerRequest: null, }))).resolves.toBeUndefined; @@ -32,10 +32,11 @@ mock.module('node-fetch', () => jest.fn(() => Promise.resolve({ describe('WorkerHttpController.addWorker', () => { let redisClient: Redis; - beforeAll(() => { + beforeAll(async () => { redisClient = new Redis({ maxRetriesPerRequest: null }); + await WorkerHttpController.loadScripts(redisClient); }); afterAll(async () => { @@ -43,7 +44,6 @@ describe('WorkerHttpController.addWorker', () => { }); it('should add a worker with valid metadata', async () => { - const response = await WorkerHttpController.addWorker({ req: fakeAddValidReq, redisClient, @@ -53,6 +53,17 @@ describe('WorkerHttpController.addWorker', () => { expect(response).toBeDefined(); expect(await response.text()).toBe("OK"); expect(response!.status).toBe(200); // Assuming 200 is the success status code + + // Verify worker was added in Redis + const workerMetadataKey = config.workerMetadataKey; + const workerMetadata = await redisClient.hgetall(workerMetadataKey); + expect(workerMetadata).toBeDefined(); + expect(workerMetadata.validQueue).toBeDefined(); + + // Verify event was added in Redis + const workerMetadataStream = config.workerMetadataStream; + const streamLength = await redisClient.xlen(workerMetadataStream); + expect(streamLength).toBeGreaterThan(0); }); it('should return a 400 response for invalid metadata', async () => { @@ -73,10 +84,11 @@ describe('WorkerHttpController.addWorker', () => { describe('WorkerHttpController.removeWorker', () => { let redisClient: Redis; - beforeAll(() => { + beforeAll(async () => { redisClient = new Redis({ maxRetriesPerRequest: null }); + await WorkerHttpController.loadScripts(redisClient); }); it('should remove a worker successfully', async () => { @@ -98,6 +110,17 @@ describe('WorkerHttpController.removeWorker', () => { const responseRemove = await WorkerHttpController.removeWorker(opts); expect(responseRemove).toBeDefined(); expect(responseRemove!.status).toBe(200); // Assuming 200 indicates success + + // Verify worker was removed from Redis + const workerMetadataKey = config.workerMetadataKey; + const workerMetadata = await redisClient.hgetall(workerMetadataKey); + expect(workerMetadata).toBeDefined(); + expect(workerMetadata.validQueue).toBeUndefined(); + + // Verify event was added in Redis + const workerMetadataStream = config.workerMetadataStream; + const streamLength = await redisClient.xlen(workerMetadataStream); + expect(streamLength).toBeGreaterThan(0); }); it('should return 404 for non existing workers', async () => { diff --git a/src/controllers/http/worker-http-controller.ts b/src/controllers/http/worker-http-controller.ts index 0e676b9..121369e 100644 --- a/src/controllers/http/worker-http-controller.ts +++ b/src/controllers/http/worker-http-controller.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import { Job, Worker } from "bullmq"; import { Redis, Cluster } from "ioredis"; @@ -10,12 +11,16 @@ import { config } from "../../config"; const debugEnabled = config.debugEnabled; const workers: { [queueName: string]: Worker } = {}; +const metadatasShas: { [queueName: string]: string } = {}; const workerMetadataKey = config.workerMetadataKey; +const workerMetadataStream = config.workerMetadataStream; +const workerStreamBlockingTime = 5000; +const abortController = new AbortController(); export const gracefulShutdownWorkers = async () => { info(`Closing workers...`); - + abortController.abort(); const closingWorkers = Object.keys(workers).map(async (queueName) => workers[queueName].close()); await Promise.all(closingWorkers); info('Workers closed'); @@ -24,7 +29,7 @@ export const gracefulShutdownWorkers = async () => { const workerFromMetadata = (queueName: string, workerMetadata: WorkerMetadata, connection: Redis | Cluster): Worker => { const { endpoint: workerEndpoint, opts: workerOptions } = workerMetadata; - debugEnabled && debug(`Starting worker for queue ${queueName} with endpoint ${workerMetadata.endpoint.url} and options ${workerMetadata.opts || 'default'}`); + debugEnabled && debug(`Starting worker for queue ${queueName} with endpoint ${workerMetadata.endpoint.url} and options ${JSON.stringify(workerMetadata.opts) || 'default'}`); const worker = new Worker(queueName, async (job: Job, token?: string) => { debugEnabled && debug(`Processing job ${job.id} from queue ${queueName} with endpoint ${workerEndpoint.url}`); @@ -72,10 +77,111 @@ const workerFromMetadata = (queueName: string, workerMetadata: WorkerMetadata, c return worker; }; +let lastEventId: string | undefined; + +export const workerStreamListener = async (redisClient: Redis | Cluster, abortSignal: AbortSignal) => { + const streamBlockingClient = redisClient.duplicate(); + let running = true; + + abortSignal.addEventListener('abort', () => { + running = false; + streamBlockingClient.disconnect(); + }); + + while (running) { + const streams = await streamBlockingClient.xread('BLOCK', workerStreamBlockingTime, 'STREAMS', workerMetadataStream, lastEventId || '0'); + + // If we got no events, continue to the next iteration + if (!streams || streams.length === 0) { + continue; + } + + const stream = streams[0]; + + debugEnabled && debug(`Received ${streams.length} event${streams.length > 1 ? "s" : ""} from stream ${workerMetadataStream}`); + + const [_streamName, events] = stream; + + for (const [eventId, fields] of events) { + + lastEventId = eventId; + const queueName = fields[1]; + const existingWorker = workers[queueName]; + const existingSha = metadatasShas[queueName]; + + const workerMetadataRaw = await redisClient.hget(workerMetadataKey, queueName); + + // If workerMetadatadaVersion is older than the event id, we need to update the worker + if (workerMetadataRaw) { + const workerMetadataSha256 = createHash('sha256').update(workerMetadataRaw).digest('hex'); + + if ((existingSha !== workerMetadataSha256)) { + const workerMetadata = JSON.parse(workerMetadataRaw); + workers[queueName] = workerFromMetadata(queueName, workerMetadata, redisClient); + metadatasShas[queueName] = workerMetadataSha256; + if (existingWorker) { + await existingWorker.close(); + } + } + } else { + // worker has been removed + debugEnabled && debug(`Worker for queue ${queueName} has been removed`); + + if (existingWorker) { + await existingWorker.close(); + delete workers[queueName]; + delete metadatasShas[queueName]; + } + } + } + } +} + export const WorkerHttpController = { - init: (redisClient: Redis | Cluster, workersRedisClient: Redis | Cluster) => { + loadScripts: async (redisClient: Redis | Cluster) => { + const luaScripts = { + updateWorkerMetadata: ` + local workerMetadataKey = KEYS[1] + local workerMetadataStream = KEYS[2] + local queueName = ARGV[1] + local workerMetadata = ARGV[2] + local streamMaxLen = ARGV[3] + redis.call('HSET', workerMetadataKey, queueName, workerMetadata) + + local eventId = redis.call('XADD', workerMetadataStream, 'MAXLEN', streamMaxLen, '*', 'worker', queueName) + return eventId + `, + removeWorkerMetadata: ` + local workerMetadataKey = KEYS[1] + local workerMetadataStream = KEYS[2] + local queueName = ARGV[1] + local streamMaxLen = ARGV[2] + local removedWorker = redis.call('HDEL', workerMetadataKey, queueName) + if removedWorker == 1 then + local eventId = redis.call('XADD', workerMetadataStream, 'MAXLEN', streamMaxLen, '*', 'worker', queueName) + return { removedWorker, eventId } + end + ` + } + + for (const [scriptName, script] of Object.entries(luaScripts)) { + redisClient.defineCommand(scriptName, { numberOfKeys: 2, lua: script }); + } + }, + + /** + * Load workers from Redis and start them. + * + * @param redisClient + * @param workersRedisClient + */ + loadWorkers: async (redisClient: Redis | Cluster, workersRedisClient: Redis | Cluster) => { // Load workers from Redis and start them debugEnabled && debug('Loading workers from Redis...'); + const result = await redisClient.xrevrange(config.workerMetadataStream, '+', '-', 'COUNT', 1); + if (result.length > 0) { + [[lastEventId]] = result + } const stream = redisClient.hscanStream(workerMetadataKey, { count: 10 }); stream.on('data', (result: string[]) => { for (let i = 0; i < result.length; i += 2) { @@ -84,10 +190,11 @@ export const WorkerHttpController = { const workerMetadata = JSON.parse(value) as WorkerMetadata; workers[queueName] = workerFromMetadata(queueName, workerMetadata, workersRedisClient); + metadatasShas[queueName] = createHash('sha256').update(value).digest('hex'); } }); - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { stream.on('end', () => { debugEnabled && debug('Workers loaded'); resolve(); @@ -98,6 +205,11 @@ export const WorkerHttpController = { }); }); }, + init: async (redisClient: Redis | Cluster, workersRedisClient: Redis | Cluster) => { + await WorkerHttpController.loadScripts(redisClient); + await WorkerHttpController.loadWorkers(redisClient, workersRedisClient); + workerStreamListener(workersRedisClient, abortController.signal); + }, /** * Add a new worker to the system. A worker is a BullMQ worker that processes @@ -123,23 +235,25 @@ export const WorkerHttpController = { } const { queue: queueName } = workerMetadata; - const { redisClient, workersRedisClient } = opts; - - // Replace worker if it already exists - const existingWorker = workers[queueName]; - const worker = workerFromMetadata(queueName, workerMetadata, workersRedisClient); - workers[queueName] = worker; + const { redisClient } = opts; - // Upsert worker metadata in Redis for the worker to be able to reconnect after a restart + // Upsert worker metadata and notify all listeners about the change. try { - await redisClient.hset(workerMetadataKey, queueName, JSON.stringify(workerMetadata)); + const eventId = await (redisClient)['updateWorkerMetadata']( + workerMetadataKey, + workerMetadataStream, + queueName, + JSON.stringify(workerMetadata), + config.maxLenWorkerMetadataStream + ); + + lastEventId = eventId as string; + return new Response('OK', { status: 200 }); } catch (err) { - return new Response('Failed to store worker metadata in Redis', { status: 500 }); - } finally { - if (existingWorker) { - await existingWorker.close(); - } + const errMsg = `Failed to store worker metadata in Redis: ${err}`; + debugEnabled && debug(errMsg); + return new Response(errMsg, { status: 500 }); } }, @@ -172,21 +286,36 @@ export const WorkerHttpController = { const { queueName } = opts.params; const { redisClient } = opts; - const worker = workers[queueName]; - delete workers[queueName]; try { - if (worker) { - await worker.close(); - } - - const removedWorker = await redisClient.hdel(workerMetadataKey, queueName); - if (removedWorker === 0 && !worker) { + const result = await (redisClient)['removeWorkerMetadata']( + workerMetadataKey, + workerMetadataStream, + queueName, + config.maxLenWorkerMetadataStream + ); + if (!result && !workers[queueName]) { return new Response('Worker not found', { status: 404 }); } + lastEventId = result[1]; + return new Response('OK', { status: 200 }); - } catch (err) { - return new Response('Failed to remove worker', { status: 500 }); + } catch (_err) { + const err = _err as Error; + debugEnabled && debug(`Failed to remove worker: ${err}`); + return new Response(`Failed to remove worker ${err.toString()}`, { status: 500 }); } + }, + + /** + * Cleans the proxy metadata from the Redis host. + * @param redisClient + * @returns + */ + cleanMetadata: async (redisClient: Redis | Cluster) => { + const multi = redisClient.multi(); + multi.del(workerMetadataKey); + multi.del(workerMetadataStream); + return multi.exec(); } } diff --git a/src/controllers/http/worker-job-http-controller.spec.ts b/src/controllers/http/worker-job-http-controller.spec.ts index 65c1b69..b35807b 100644 --- a/src/controllers/http/worker-job-http-controller.spec.ts +++ b/src/controllers/http/worker-job-http-controller.spec.ts @@ -18,6 +18,7 @@ beforeAll(async () => { afterAll(async () => { await redisClient.quit(); + await workersRedisClient.quit(); }); describe('WorkerJobHttpController.updateProgress', () => { @@ -38,9 +39,6 @@ describe('WorkerJobHttpController.updateProgress', () => { const response = await WorkerJobHttpController.updateProgress(opts); expect(response.status).toBe(500); expect(await response.text()).toBe('Missing key for job 1. updateProgress'); - - await opts.redisClient.quit(); - await opts.workersRedisClient.quit(); }); it('updates job progress and returns a 200 response', async () => { @@ -130,7 +128,7 @@ describe('WorkerJobHttpController.getLogs', () => { expect(await response.text()).toBe('Invalid start or length'); }); - it.only('returns a 200 response with the logs', async () => { + it('returns a 200 response with the logs', async () => { const jobId = "42"; const logsKey = `${queuePrefix}:valid:${jobId}:logs`; diff --git a/src/e2e-test.ts b/src/e2e-test.ts index 284c0ec..80fd6e7 100644 --- a/src/e2e-test.ts +++ b/src/e2e-test.ts @@ -1,10 +1,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, mock } from "bun:test"; import { Server } from "bun"; -import { startProxy } from "./proxy"; +import { cleanProxy, startProxy } from "./proxy"; import { Redis } from "ioredis"; import { config } from "./config"; import { JobJson, Queue } from "bullmq"; import { cleanCache } from "./utils/queue-factory"; +import { WorkerHttpController } from "./controllers/http/worker-http-controller"; const token = 'test-token'; @@ -40,11 +41,14 @@ describe("e2e", () => { await cleanCache(); await queue.close(); + + await cleanProxy(redisClient); + await redisClient.quit(); }); it("process a job updating progress and adding logs", async () => { - const proxy = await startProxy(0, redisClient, redisClient, { skipInitWorkers: true }); + const proxy = await startProxy(0, redisClient, redisClient.duplicate()); const proxyPort = proxy.port; let server: Server; @@ -103,8 +107,10 @@ describe("e2e", () => { "Authorization": `Bearer ${token}` }, }); + expect(addJobResponse.status).toBe(200); const jobsAdded = await addJobResponse.json(); + expect(jobsAdded).toHaveLength(1); expect(jobsAdded[0]).toHaveProperty('id'); expect(jobsAdded[0]).toHaveProperty('name', 'test-job'); @@ -129,7 +135,8 @@ describe("e2e", () => { "Authorization": `Bearer ${token}` }, }); - + + expect(await workerResponse.text()).toBe("OK"); expect(workerResponse.status).toBe(200); await processingJob; diff --git a/src/proxy.spec.ts b/src/proxy.spec.ts index d696bc8..16489eb 100644 --- a/src/proxy.spec.ts +++ b/src/proxy.spec.ts @@ -47,8 +47,9 @@ describe('Proxy', () => { mockUpgrade.mockClear(); }); - it('should start the proxy with the correct configuration', async () => { - const redisClientMock = { + // Skipping as some issue with bun prevents closing the server and the test from finishing + it.skip('should start the proxy with the correct configuration', async () => { + const redisClientMock = { hscanStream: jest.fn(() => { // on('end') Must be called after on('data') return { @@ -60,10 +61,19 @@ describe('Proxy', () => { } }), }; - }) + }), + defineCommand: jest.fn(), + xrevrange: jest.fn(() => { + return []; + }), + duplicate: jest.fn(() => redisClientMock), + xread: jest.fn(() => { + return []; + }), } as Redis; - await startProxy(3000, redisClientMock, redisClientMock, { skipInitWorkers: true }); + const server = await startProxy(3000, redisClientMock, redisClientMock); + expect(Bun.serve).toHaveBeenCalledTimes(1); expect(Bun.serve).toHaveBeenCalledWith( @@ -73,5 +83,7 @@ describe('Proxy', () => { websocket: expect.any(Object) }) ); + + server.stop(true); }); }); diff --git a/src/proxy.ts b/src/proxy.ts index 28ec183..ce30901 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -50,21 +50,30 @@ const websocket = { perMessageDeflate: false, }; +/** + * Options available for the proxy. + */ export interface ProxyOpts { - skipInitWorkers?: boolean; -} +}; + +/** + * Cleans the proxy metadata from the Redis host. + * @param connection + */ +export const cleanProxy = async (connection: Redis | Cluster, +) => { + return WorkerHttpController.cleanMetadata(connection); +}; export const startProxy = async ( port: number, connection: Redis | Cluster, workersConnection: Redis | Cluster, - opts: ProxyOpts = {}, + _opts: ProxyOpts = {}, ) => { console.log(chalk.gray(asciiArt)) - if (opts.skipInitWorkers !== true) { - await WorkerHttpController.init(connection, workersConnection); - } + await WorkerHttpController.init(connection, workersConnection); const server = Bun.serve({ port,