From 854f410aadc5c8ea357b2508bf59bc1cf086828f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 18:12:57 -0500 Subject: [PATCH] feat(batch15): complete group 5 msgblock/consumerfilestore --- .../JetStream/MessageBlock.cs | 819 +++++++++++++++++- porting.db | Bin 6647808 -> 6651904 bytes reports/current.md | 8 +- 3 files changed, 822 insertions(+), 5 deletions(-) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MessageBlock.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MessageBlock.cs index e049499..32aef58 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MessageBlock.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MessageBlock.cs @@ -33,6 +33,11 @@ namespace ZB.MOM.NatsNet.Server; /// internal sealed class MessageBlock { + private const int MaxVarIntLength = 10; + private static readonly Exception ErrNoCache = new InvalidOperationException("no message cache"); + private static readonly Exception ErrPartialCache = new InvalidOperationException("partial cache"); + private static readonly Exception ErrDeletedMsg = new InvalidOperationException("deleted message"); + // ------------------------------------------------------------------ // Identity fields — first/last use volatile-style access in Go via // atomic.LoadUint64 on the embedded msgId structs. @@ -1128,6 +1133,735 @@ internal sealed class MessageBlock return (raw & ~(FileStoreDefaults.Dbit | FileStoreDefaults.Cbit), deleted); } + // Will do a lookup from cache. + // This will copy the msg from the cache. + // Lock should be held. + internal (StoreMsg? Message, Exception? Error) CacheLookup(ulong seq, StoreMsg? sm) + => CacheLookupEx(seq, sm, doCopy: true); + + // Will do a lookup from cache. + // This will NOT copy the msg from the cache. + // Lock should be held. + internal (StoreMsg? Message, Exception? Error) CacheLookupNoCopy(ulong seq, StoreMsg? sm) + => CacheLookupEx(seq, sm, doCopy: false); + + // Will do a lookup from cache. + // Lock should be held. + internal (StoreMsg? Message, Exception? Error) CacheLookupEx(ulong seq, StoreMsg? sm, bool doCopy) + { + var fseq = First.Seq; + var lseq = Last.Seq; + if ((fseq > 0 && lseq == fseq - 1) || seq < fseq || seq > lseq) + return (null, StoreErrors.ErrStoreMsgNotFound); + + if (Llseq == 0 || seq < Llseq || seq == Llseq + 1 || seq + 1 == Llseq) + Llseq = seq; + + if (Dmap.Exists(seq)) + { + Llts = JetStreamFileStore.TimestampNormalized(DateTime.UtcNow); + return (null, ErrDeletedMsg); + } + + var cache = CacheData; + if (cache == null || cache.Fseq == 0 || cache.Idx.Length == 0 || cache.Buf.Length == 0) + { + if (cache != null) + TryForceExpireCacheLocked(); + return (null, ErrNoCache); + } + + if (seq < cache.Fseq) + return (null, ErrPartialCache); + + var slot = seq - cache.Fseq; + if (slot >= (ulong)cache.Idx.Length) + return (null, StoreErrors.ErrStoreMsgNotFound); + + var raw = cache.Idx[(int)slot]; + var deleted = (raw & FileStoreDefaults.Dbit) != 0; + var hashChecked = (raw & FileStoreDefaults.Cbit) != 0; + if (deleted) + return (null, ErrDeletedMsg); + + Llts = JetStreamFileStore.TimestampNormalized(DateTime.UtcNow); + + var offset = (int)(raw & ~(FileStoreDefaults.Dbit | FileStoreDefaults.Cbit)); + if ((uint)offset >= (uint)cache.Buf.Length) + return (null, ErrPartialCache); + + var (fsm, parseErr) = MsgFromBufEx(cache.Buf.AsSpan(offset), sm, verifyChecksum: !hashChecked, doCopy: doCopy); + if (parseErr != null || fsm == null) + return (null, parseErr ?? StoreErrors.ErrStoreMsgNotFound); + + if (fsm.Seq == 0) + return (null, ErrDeletedMsg); + + if (seq != fsm.Seq) + { + TryForceExpireCacheLocked(); + return (null, new InvalidOperationException($"sequence numbers for cache load did not match, {seq} vs {fsm.Seq}")); + } + + if (!hashChecked) + cache.Idx[(int)slot] = raw | FileStoreDefaults.Cbit; + + return (fsm, null); + } + + // Internal function to return msg parts from a raw buffer. + // Raw buffer will be copied into sm. + // Lock should be held. + internal (StoreMsg? Message, Exception? Error) MsgFromBuf(ReadOnlySpan buf, StoreMsg? sm, bool verifyChecksum) + => MsgFromBufEx(buf, sm, verifyChecksum, doCopy: true); + + // Internal function to return msg parts from a raw buffer. + // Raw buffer will NOT be copied into sm. + // Lock should be held. + internal (StoreMsg? Message, Exception? Error) MsgFromBufNoCopy(ReadOnlySpan buf, StoreMsg? sm, bool verifyChecksum) + => MsgFromBufEx(buf, sm, verifyChecksum, doCopy: false); + + // Internal function to return msg parts from a raw buffer. + // copy boolean determines if message/header buffers are copied. + // Lock should be held. + internal (StoreMsg? Message, Exception? Error) MsgFromBufEx(ReadOnlySpan buf, StoreMsg? sm, bool verifyChecksum, bool doCopy) + { + if (!TryReadRecordHeader(buf, out var seqRaw, out var ts, out var subjectLength, out var headerLength, out var msgLength, out var recordLength, out var parseErr)) + return (null, parseErr ?? new ErrBadMsg(Mfn, "record too short")); + + if (recordLength > buf.Length) + return (null, new ErrBadMsg(Mfn, "record extends past buffer")); + + var record = buf[..recordLength]; + var payloadLength = recordLength - FileStoreDefaults.RecordHashSize; + var payload = record[..payloadLength]; + var checksum = record[payloadLength..]; + + if (verifyChecksum) + { + var computed = SHA256.HashData(payload); + if (!computed.AsSpan(0, FileStoreDefaults.RecordHashSize).SequenceEqual(checksum)) + return (null, new ErrBadMsg(Mfn, "invalid checksum")); + } + + var seq = seqRaw; + if ((seq & FileStoreDefaults.Ebit) != 0) + seq = 0; + seq &= ~FileStoreDefaults.Tbit; + + sm ??= new StoreMsg(); + sm.Clear(); + sm.Seq = seq; + sm.Ts = ts; + + var subjectOffset = 8 + 8 + 4 + 4 + 4; + var headerOffset = subjectOffset + subjectLength; + var msgOffset = headerOffset + headerLength; + if (msgOffset + msgLength > record.Length) + return (null, new ErrBadMsg(Mfn, "invalid message bounds")); + + sm.Subject = subjectLength > 0 + ? Encoding.UTF8.GetString(record.Slice(subjectOffset, subjectLength)) + : string.Empty; + + var headerSlice = record.Slice(headerOffset, headerLength); + var msgSlice = record.Slice(msgOffset, msgLength); + + if (doCopy) + { + sm.Hdr = headerSlice.ToArray(); + sm.Msg = msgSlice.ToArray(); + } + else + { + // StoreMsg uses byte[] fields, so no-copy mode still exposes slices as arrays. + sm.Hdr = headerSlice.ToArray(); + sm.Msg = msgSlice.ToArray(); + } + + sm.Buf = new byte[sm.Hdr.Length + sm.Msg.Length]; + if (sm.Hdr.Length > 0) + Buffer.BlockCopy(sm.Hdr, 0, sm.Buf, 0, sm.Hdr.Length); + if (sm.Msg.Length > 0) + Buffer.BlockCopy(sm.Msg, 0, sm.Buf, sm.Hdr.Length, sm.Msg.Length); + + return (sm, null); + } + + // readIndexInfo will read in the index information for this message block. + internal Exception? ReadIndexInfo() + { + var dir = Path.GetDirectoryName(Mfn); + if (string.IsNullOrWhiteSpace(dir)) + return new InvalidOperationException("message block path is missing directory"); + + var ifn = Path.Combine(dir, string.Format(FileStoreDefaults.IndexScan, Index)); + byte[] buf; + try + { + buf = File.ReadAllBytes(ifn); + } + catch (Exception ex) + { + return ex; + } + + if (Liwsz == 0) + Liwsz = buf.Length; + + if (Aek != null) + { + if (Nonce == null || Nonce.Length == 0) + return new InvalidOperationException("missing block nonce"); + + try + { + buf = Aek.Open(Nonce, buf); + } + catch (Exception ex) + { + return ex; + } + } + + var headerErr = JetStreamFileStore.CheckNewHeader(buf); + if (headerErr != null) + { + TryDeleteFile(ifn); + return new InvalidDataException("bad index file"); + } + + var bi = FileStoreDefaults.HdrLen; + if (!TryReadUVarInt(buf, ref bi, out var msgs) || + !TryReadUVarInt(buf, ref bi, out var bytes) || + !TryReadUVarInt(buf, ref bi, out var firstSeq) || + !TryReadVarInt(buf, ref bi, out var firstTs) || + !TryReadUVarInt(buf, ref bi, out var lastSeq) || + !TryReadVarInt(buf, ref bi, out var lastTs) || + !TryReadUVarInt(buf, ref bi, out var dmapLen)) + { + TryDeleteFile(ifn); + return new InvalidDataException("short index file"); + } + + Msgs = msgs; + Bytes = bytes; + First = new MsgId { Seq = firstSeq, Ts = firstTs }; + Last = new MsgId { Seq = lastSeq, Ts = lastTs }; + + var expected = Last.Seq >= First.Seq ? Last.Seq - First.Seq + 1 : 0; + var activeExpected = expected >= dmapLen ? expected - dmapLen : 0; + if (Msgs != activeExpected) + { + TryDeleteFile(ifn); + return new InvalidDataException("accounting inconsistent"); + } + + if (bi < 0 || bi + FileStoreDefaults.RecordHashSize > buf.Length) + { + TryDeleteFile(ifn); + return new InvalidDataException("short index file"); + } + + Lchk = buf.AsSpan(bi, FileStoreDefaults.RecordHashSize).ToArray(); + bi += FileStoreDefaults.RecordHashSize; + + if (dmapLen > 0) + { + if (buf[1] == FileStoreDefaults.NewVersion) + { + try + { + var (decoded, consumed) = SequenceSet.Decode(buf.AsSpan(bi)); + if (consumed <= 0) + return new InvalidDataException("could not decode dmap"); + + Dmap = decoded; + } + catch (Exception ex) + { + return ex; + } + } + else + { + var fseq = First.Seq; + for (var i = 0UL; i < dmapLen; i++) + { + if (!TryReadUVarInt(buf, ref bi, out var relSeq)) + break; + + Dmap.Insert(relSeq + fseq); + } + } + } + + return null; + } + + // Return all active tombstones in this block. + internal MsgId[] Tombs() + { + Mu.EnterWriteLock(); + try + { + return TombsLocked(); + } + finally + { + Mu.ExitWriteLock(); + } + } + + // Return all active tombstones in this block. + // Write lock should be held. + internal MsgId[] TombsLocked() + { + if (CacheNotLoaded()) + { + var loadErr = LoadMsgsWithLock(); + if (loadErr != null) + return Array.Empty(); + } + + try + { + var cache = CacheData; + if (cache == null || cache.Buf.Length == 0) + return Array.Empty(); + + var tombs = new List(); + var offset = 0; + while (offset < cache.Buf.Length) + { + if (!TryReadRecordHeader(cache.Buf.AsSpan(offset), out var seqRaw, out var ts, out _, out _, out _, out var recordLength, out _)) + break; + + if (recordLength <= 0 || offset + recordLength > cache.Buf.Length) + break; + + if ((seqRaw & FileStoreDefaults.Tbit) != 0) + tombs.Add(new MsgId { Seq = seqRaw & ~FileStoreDefaults.Tbit, Ts = ts }); + + offset += recordLength; + } + + return tombs.ToArray(); + } + finally + { + FinishedWithCache(); + } + } + + // fs lock should be held. + internal int NumPriorTombs() + { + Mu.EnterWriteLock(); + try + { + return NumPriorTombsLocked(); + } + finally + { + Mu.ExitWriteLock(); + } + } + + // Return number of tombstones for messages prior to this block. + // Write lock should be held for block. + internal int NumPriorTombsLocked() + { + if (CacheNotLoaded()) + { + var loadErr = LoadMsgsWithLock(); + if (loadErr != null) + return 0; + } + + try + { + var cache = CacheData; + if (cache == null || cache.Buf.Length == 0) + return 0; + + var fsFirstSeq = Fs?.State().FirstSeq ?? 0; + ulong blockFirst = 0; + var tombs = 0; + var offset = 0; + while (offset < cache.Buf.Length) + { + if (!TryReadRecordHeader(cache.Buf.AsSpan(offset), out var seqRaw, out _, out _, out _, out _, out var recordLength, out _)) + break; + + if (recordLength <= 0 || offset + recordLength > cache.Buf.Length) + break; + + if ((seqRaw & FileStoreDefaults.Tbit) != 0) + { + var tseq = seqRaw & ~FileStoreDefaults.Tbit; + if (tseq >= fsFirstSeq && (blockFirst == 0 || tseq < blockFirst)) + tombs++; + + offset += recordLength; + continue; + } + + var seq = seqRaw & ~FileStoreDefaults.Ebit; + if (seq != 0 && (seqRaw & FileStoreDefaults.Ebit) == 0 && blockFirst == 0) + blockFirst = seq; + + offset += recordLength; + } + + return tombs; + } + finally + { + FinishedWithCache(); + } + } + + // Called by purge to simply get rid of cache and close fds. + internal void DirtyClose() + { + Mu.EnterWriteLock(); + try + { + _ = DirtyCloseWithRemove(remove: false); + } + finally + { + Mu.ExitWriteLock(); + } + } + + // Should be called with write lock held. + internal Exception? DirtyCloseWithRemove(bool remove) + { + if (Ctmr != null) + { + Ctmr.Dispose(); + Ctmr = null; + } + + ClearCacheAndOffset(); + + Qch?.Writer.TryComplete(); + Qch = null; + + Mfd?.Dispose(); + Mfd = null; + + if (!remove) + return null; + + Fss = null; + if (!string.IsNullOrEmpty(Mfn)) + { + try + { + File.Delete(Mfn); + Mfn = string.Empty; + } + catch (Exception ex) + { + return ex; + } + } + + if (!string.IsNullOrEmpty(Kfn)) + { + try + { + File.Delete(Kfn); + } + catch (Exception ex) + { + return ex; + } + } + + return null; + } + + // Remove a sequence from per-subject state and track whether first/last need recompute. + // Lock should be held. + internal ulong RemoveSeqPerSubject(string subj, ulong seq) + { + _ = EnsurePerSubjectInfoLoaded(); + if (Fss == null || string.IsNullOrEmpty(subj)) + return 0; + + var bsubj = Encoding.UTF8.GetBytes(subj); + var (ss, ok) = Fss.Find(bsubj); + if (!ok || ss == null) + return 0; + + if (ss.Msgs == 1) + { + Fss.Delete(bsubj); + return 0; + } + + ss.Msgs--; + + if (ss.Msgs == 1) + { + if (!ss.LastNeedsUpdate && seq != ss.Last) + { + ss.First = ss.Last; + ss.FirstNeedsUpdate = false; + return 1; + } + + if (!ss.FirstNeedsUpdate && seq != ss.First) + { + ss.Last = ss.First; + ss.LastNeedsUpdate = false; + return 1; + } + } + + ss.FirstNeedsUpdate = seq == ss.First || ss.FirstNeedsUpdate; + ss.LastNeedsUpdate = seq == ss.Last || ss.LastNeedsUpdate; + return ss.Msgs; + } + + // Recalculate first/last sequence values for a subject in this block. + // Lock should be held. + internal void RecalculateForSubj(string subj, SimpleState ss) + { + if (string.IsNullOrEmpty(subj) || ss == null) + return; + + var loadedHere = false; + if (CacheNotLoaded()) + { + if (LoadMsgsWithLock() != null) + return; + loadedHere = true; + } + + try + { + var cache = CacheData; + if (cache == null || cache.Idx.Length == 0 || cache.Buf.Length == 0) + return; + + var startSlot = (int)Math.Max(0, (long)ss.First - (long)cache.Fseq); + if (startSlot >= cache.Idx.Length) + { + ss.First = ss.Last; + ss.FirstNeedsUpdate = false; + ss.LastNeedsUpdate = false; + return; + } + + var endSlot = (int)Math.Max(0, (long)ss.Last - (long)cache.Fseq); + if (endSlot >= cache.Idx.Length) + endSlot = cache.Idx.Length - 1; + if (startSlot > endSlot) + return; + + if (ss.FirstNeedsUpdate) + { + ss.FirstNeedsUpdate = false; + var minSeq = ss.First + 1; + if (minSeq < First.Seq) + minSeq = First.Seq; + + var scratch = new StoreMsg(); + for (var slot = startSlot; slot < cache.Idx.Length; slot++) + { + var raw = cache.Idx[slot] & ~FileStoreDefaults.Cbit; + if (raw == FileStoreDefaults.Dbit) + continue; + + var offset = (int)raw; + if ((uint)offset >= (uint)cache.Buf.Length) + { + ss.First = ss.Last; + ss.LastNeedsUpdate = false; + return; + } + + var (sm, err) = MsgFromBufNoCopy(cache.Buf.AsSpan(offset), scratch, verifyChecksum: false); + if (err != null || sm == null || sm.Seq == 0) + continue; + if (sm.Subject != subj || sm.Seq < minSeq || Dmap.Exists(sm.Seq)) + continue; + + ss.First = sm.Seq; + if (ss.Msgs == 1) + { + ss.Last = sm.Seq; + ss.LastNeedsUpdate = false; + return; + } + + startSlot = slot; + break; + } + } + + if (ss.LastNeedsUpdate) + { + ss.LastNeedsUpdate = false; + var maxSeq = ss.Last > 0 ? ss.Last - 1 : 0; + if (maxSeq > Last.Seq) + maxSeq = Last.Seq; + + var scratch = new StoreMsg(); + for (var slot = endSlot; slot >= startSlot; slot--) + { + var raw = cache.Idx[slot] & ~FileStoreDefaults.Cbit; + if (raw == FileStoreDefaults.Dbit) + continue; + + var offset = (int)raw; + if ((uint)offset >= (uint)cache.Buf.Length) + return; + + var (sm, err) = MsgFromBufNoCopy(cache.Buf.AsSpan(offset), scratch, verifyChecksum: false); + if (err != null || sm == null || sm.Seq == 0) + continue; + if (sm.Subject != subj || sm.Seq > maxSeq || Dmap.Exists(sm.Seq)) + continue; + + ss.Last = sm.Seq < ss.First ? ss.First : sm.Seq; + if (ss.Msgs == 1) + { + ss.First = ss.Last; + ss.FirstNeedsUpdate = false; + } + return; + } + } + } + finally + { + if (loadedHere) + FinishedWithCache(); + } + } + + // Lock should be held. + internal Exception? ResetPerSubjectInfo() + { + Fss = null; + return GeneratePerSubjectInfo(); + } + + // generatePerSubjectInfo rebuilds per-subject state from the cached block data. + // Lock should be held. + internal Exception? GeneratePerSubjectInfo() + { + if (Msgs == 0) + return null; + + var loadedHere = false; + if (CacheNotLoaded()) + { + var loadErr = LoadMsgsWithLock(); + if (loadErr != null) + return loadErr; + + if (Fss != null) + return null; + + loadedHere = true; + } + + try + { + Fss = new SubjectTree(); + var scratch = new StoreMsg(); + for (var seq = First.Seq; seq <= Last.Seq; seq++) + { + if (Dmap.Exists(seq)) + { + if (seq == ulong.MaxValue) + break; + continue; + } + + var (sm, err) = CacheLookupNoCopy(seq, scratch); + if (err != null) + { + if (ReferenceEquals(err, StoreErrors.ErrStoreMsgNotFound) || IsDeletedMsgError(err)) + { + if (seq == ulong.MaxValue) + break; + continue; + } + + if (ReferenceEquals(err, ErrNoCache)) + return null; + + return err; + } + + if (sm != null && !string.IsNullOrEmpty(sm.Subject)) + { + var subjBytes = Encoding.UTF8.GetBytes(sm.Subject); + var (state, exists) = Fss.Find(subjBytes); + if (exists && state != null) + { + state.Msgs++; + state.Last = seq; + state.LastNeedsUpdate = false; + } + else + { + Fss.Insert(subjBytes, new SimpleState + { + Msgs = 1, + First = seq, + Last = seq, + }); + } + } + + if (seq == ulong.MaxValue) + break; + } + + if (Fss.Size() > 0) + { + Llts = JetStreamFileStore.TimestampNormalized(DateTime.UtcNow); + Lsts = Llts; + StartCacheExpireTimer(); + } + return null; + } + finally + { + if (loadedHere) + FinishedWithCache(); + } + } + + // Helper to make sure per-subject state is loaded if we are tracking subjects. + // Lock should be held. + internal Exception? EnsurePerSubjectInfoLoaded() + { + if (Fss != null || NoTrack) + { + if (Fss != null) + Lsts = JetStreamFileStore.TimestampNormalized(DateTime.UtcNow); + return null; + } + + if (Msgs == 0) + { + Fss = new SubjectTree(); + return null; + } + + return GeneratePerSubjectInfo(); + } + internal void SpinUpFlushLoop() { Mu.EnterWriteLock(); @@ -2195,14 +2929,29 @@ internal sealed class MessageBlock } } - private TimeSpan SinceLastActivity() + internal TimeSpan SinceLastActivity() { + if (Closed) + return TimeSpan.Zero; + var now = JetStreamFileStore.TimestampNormalized(DateTime.UtcNow); var last = Math.Max(Math.Max(Lrts, Llts), Math.Max(Lwts, Lsts)); var delta = now - last; return NanosecondsToTimeSpan(delta); } + // Determine time since last write or remove of a message. + // Read lock should be held. + internal TimeSpan SinceLastWriteActivity() + { + if (Closed) + return TimeSpan.Zero; + + var now = JetStreamFileStore.TimestampNormalized(DateTime.UtcNow); + var last = Math.Max(Lwts, Lrts); + return NanosecondsToTimeSpan(now - last); + } + private static long TimeSpanToNanoseconds(TimeSpan value) => value <= TimeSpan.Zero ? 0 : checked(value.Ticks * 100L); @@ -2360,6 +3109,74 @@ internal sealed class MessageBlock return true; } + private static bool TryReadUVarInt(ReadOnlySpan source, ref int index, out ulong value) + { + value = 0; + var shift = 0; + for (var i = 0; i < MaxVarIntLength; i++) + { + if ((uint)index >= (uint)source.Length) + { + index = -1; + value = 0; + return false; + } + + var b = source[index++]; + if (b < 0x80) + { + if (i == MaxVarIntLength - 1 && b > 1) + { + index = -1; + value = 0; + return false; + } + + value |= (ulong)b << shift; + return true; + } + + value |= (ulong)(b & 0x7F) << shift; + shift += 7; + } + + index = -1; + value = 0; + return false; + } + + private static bool TryReadVarInt(ReadOnlySpan source, ref int index, out long value) + { + if (!TryReadUVarInt(source, ref index, out var unsigned)) + { + value = 0; + return false; + } + + value = (long)(unsigned >> 1); + if ((unsigned & 1) != 0) + value = ~value; + + return true; + } + + private static bool IsDeletedMsgError(Exception? ex) + => ex is InvalidOperationException ioe && + string.Equals(ioe.Message, ErrDeletedMsg.Message, StringComparison.Ordinal); + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // best effort + } + } + private static bool TryValidateRecordBuffer(ReadOnlySpan buf, out int validLength) { validLength = 0; diff --git a/porting.db b/porting.db index 0104dd880f58cdffbfe1a28f2c0dd8b5b965eeb1..9cfc20a43e8a58b4eb1750d78259d860612cc6c9 100644 GIT binary patch delta 4960 zcmbVPYfuzd7Vhrpo<~o2Pdg2WFfh#^3dkE30%#yYd=VcJqehKDfEYy}fF`>b>6tZ+ znxthb3CCo!#u$wnUtp9rtj5_#EZ0rI+Dg`}skp}7Y*|?=Wh|?bDlD_72ZWh*{|roh z17DwezjMy*bI-jf*WNHGN|&iKmf1lN=HLgM}SOYTG2wR6tfQH#ry))$b*1+?dv#AvF1Z=xQCc)VS4ndzghnh^zvwcrgj413v z_UH%$WTT$Dkd1nN1KFtO4rGGfH32Ey##7L9luZG?p3Q`v zyIc&NomDUm%Ieuhpgu&y*Y)@{YH(~|cSFt~9|yONu{>Pfz)lLXnj6?nL(}#*unD6E zeV~CYa}0T!v4u?zvNUXAb4h6VA|?)+-ex&KGYg-$v!0+aKew|NY49}DZt%CW5pZ`a z>l!sf!b|LS@Z1@P3Q=|*ZmOto^(8hwXb7XSXGa8BxYp%j;FcO32qbi{NB_?dGu-ZC zg1mK1-o_&A2oAyPleY;z3mK(B|MJ25(b&gI(=H>(y!9irBMQ{L_09o~pXeU7gJ>Q2+(4VY#9C(iwqKNCj zh~hKx+&%6Vcil44a>crv`pI7?aE? zj(<7+=D5I@@;ms=d;@=p{{w%Lf8WvPIAXtOKf~{`ALCRsGX(;&odpZ;GQf z)iys{_bl$6_e0pe5cVE?87m%yF3O4U(E zs0$$Xh^HZ|M>NBiJ)$NUQ}1^LO9b3HBgTjpXUNlWP#jOiP?kvOJ0arlDCn1{)ZPz8 zJ3}4`i`TI{AvukQo1ck! zWIinD7t8cRpNP|~=@UmgqGd+N=ptcaP=1BT1mOC`Xs583s2*9pd}I|cy$X2rCA`@V9zt~1*z)$_+^bAL9kmIBf#}m;uIyDiQ0ILSX=psiaA&fYdL*}?0 zM}lneA+|Wj1ytxp1sQkl$N^x9K{guj1Z1v`8v`O@oPJ}#IpLDq<|a5Qg0hO{LE$*x z#qG2&U~`!Lv}#&uOQIt51ev@;>fAbc4yjkw%kP-c+7BZk0e0t z9w|H0SE?mS1WC>1v-!Cc88<6)wRaXgcUY1j{+MKgMaLxzeAndJ2@UOWA~_A(+vC#X zTAp58TeGpgwnnaNY?fy?H*Rc_r^^*pE$ij%Jouq4G9e~AyI@+rZ>E-HsF$I7SwMY7 zkoucpYT5#tObT%mp88C3!M$VBcq6u6o!=+9NQ(bapR!;2)JkK*J7Dmrq(=GLw22WI zzy=IpLm&Vz85BVvzX?!&*=>c)?U17ZRJseVcIN2vZt)gPt$$E*Hm)jvV?$MpJR zf4VqyKqkEG8air@%+QeR&KeyH z2a?^F;C6TH@Q^(z?wla&S1Iln;E|v4a?q6OE({oKfgHSz+mj&EZjaY5r@H$|$a#Bq z99)y^0$fUSrv)i6>Fz^NJvDLI`uB8qP^93`4ikqtOrQ&<8`FCQcg2U zKH|ZWPj2^|2=nz1*LaHNH01KI#PR|qHs7eQDjC9*8>%M#n`(KAh2|y`dqG?$?~d|q z)Fh*Xv%S-@`Rss$GIX%CQobIhgQ5L{i!l`nUi4%|`j%)>M&wH{@+AS`XHa}~YT-g@Sp)lo=hkA!y8)wKRqFiD?F3&qFH`i+aPepA@>%3(Z zOIu*^J}DN~SIdjS^f0_}n`E^qA)dFLBkaT=Vg8J{%a~0ksK>D>zihw9_7Q`YTM*wZ zeI0{KmH)s}1%d&-54&o^Vc7y_HhG*7(I+WkMl-y8tF&Ch#R|-KMIhe|MjaWQIMB2E z&Ph%vJSV*~HuXhXj-kF7)fWfUYeUrUp9o_tTF<-Eca(lhmj+0hf0!6F3e2Vd<0Glk zWOU_LH?~MkH9wLFK&UtfY-CvB$KjhTKAwTNp^}X-q8kW9fHc$_>|q z;!%}ERhg(NNvbkQRVJ%SvZ|!0N~)?%QI#}RNmrE&RmoJ9ELF)?l^j*cRh6l#GEG&c zt4f}#l;~MY@8R=WAd5i?BFu|%of*bF@IXt1w@ac1&vtvpXjyHR* zMEX`}vLU|=<(CEI*WZ`NvO75@Wqn7h=bu!hZ?@(#r01jb{DAZtl=eRFIW;C#SoMPE zFOj~-w3r9qcVL?=_=V?Sn93t9e1f6*KP}wYdZ=lJxxe&m2~$`e>S0^7Xrp&G;tDhd zR={f-Zeq~5*^>wx+B^c}w}rhT!}4v~_y=z{Lv0D=vtadR&xc_KKC}(GnilnR^!Q5= zWr#-+vk`L;a}o0p^AY8U#}Eq;3lWPDixH0_mLMt+OA${XmLZlSRv?~4tVFCrtVTSA zSc9lUR3WMnPwPGYnuW*5^L!CuwX_qKb^-WuZYJK93v3&$deytrvrWc7-g&gqQwoe1 z7}`^c^ssx%@EQDCxAZ2Rg!@y(JJ(44Vm%iKZ44=NNL+tH4#kO8bvL`C9Tm)d*5V4Li-Bo(DWau?8O;Gz z&l3@Tvz27P>8)f0teMKpfx>uM!xo~zzo#-9Jrka}NRNagGZfjna~cz(;j>Ldg8Mg< zGH0vg$Y89#JDI9p$4^2*5APU7TVrX*Mc4Y7E~`**>|^}&M6-gT=m};iMKQT%7XIf= zm7>_YOlRw5(Sfi?{5Oa2hzoIBmqp?0MeydCKn6rN3nEAjLJGC>3Kh~%+8Tqaa1u>Iqd zp|&P93%NjUa(Jw0vrt0A_7j1mCI`cf3i1O zPYbP3`iX!|`#chSYhVNCo)$6_nSnQi!}haf$+E0g;WfL=2d7raq1L=-1jK`gh-BqG zBkMdp-0FN@o_i9QE>ZVvf2MF8lsHfU2XBTnP zg8N_YzlwvdS6mxF>m~=GxSP1(vuyPtTRbPI?FNgM3mN7Ajm2Z=;GXeu9$9U^1d z#LoQT5ZTGlv#o8flUm2bDb##N8Kr!se4@Oo^eMZQt;$o%a;08*K&etDDus%sc;&C< z7iB|UB`=j1$TQ?@*(bZCpQLZ0yq_4>@qY3Y9vijSddrW#zPB!?vy-{as(*{jrdwQ! zvy(c1^+jAV+9}&4wBxpkXuY<1(01A;pl!5`M_X$fi`HzL11)OXLkw!Itu)%~tH%F? zHpRA&(d?PxPodc}#rL7vGsSO2vuBDot<7(fN|#i6UD+fkc$#Pfdr(-Krau zeX0{ax;;A|$OhKq2$J)cz|k(1K=m=z3-2FQ_e1n0KBr?F%cZ?Q$(Lnb zn(X;m=yMw2K;jX+3sn-k*EYK&GVqK_q_KDg3cmj-8=>s|%zrK()7d0V_%305mfRJ((M7gyGq zpe^%;tnXI_2WU37(kz2f4VvHj%M-zfgN=pFTcC1va2kBJIyk4JKA6jxV_|Ji*blo` z2R$IS1Qqyub8rLbi`*eEWsZrbn1?CmB~mm(^st(m99F$4*bQ0-zIrdX0?v^$dD_6u+Aco+B7uWiK4YmJqUaLGhad)+@TIWYHtX1nW-`NPY z20L^=7H#o!uq-!FoV>7hiu$s{h-yak=J=Fr`za+=E|JEG zs_+lN?+&>Z@QvJRXAAqRqm9{1zYJQJcO4sBVrIdj4^%&t?^i$Yie@mLy#}+_B(guA zTvE8HIJjqD*cabaIq_dH&<4~H+_yh`T8OPSwfJLmC5XJPW+WG2`+;x@EIkn3lBj64 z^@P7<4Vq~xw*4q}{?SC|V-5gwC(TM85|bLKF=JeC>{Xn` z>RoCv6z|0owX;**VwHBPC!9b>XLhxA_lJ=?kSWMiWEyfOQiV)M?n3THW+3+