diff --git a/docs/14-outbound-storage.md b/docs/14-outbound-storage.md index e9db6c80..cc51dba9 100644 --- a/docs/14-outbound-storage.md +++ b/docs/14-outbound-storage.md @@ -6,7 +6,9 @@ Accepted sources remain owned until provider processing and every required track Only **`memory/memory/memory`** is implemented: unfinished work is lost on process closure or restart. The contracts are message-neutral; the current standalone provider adapter executes SMPP-client submissions. -This is the initial slice of [#338](https://github.com/cytechmobile/sendium/issues/338), under [#337](https://github.com/cytechmobile/sendium/issues/337). +The working lifecycle comes from [#338](https://github.com/cytechmobile/sendium/issues/338), with pending-source +contracts, multipart admission and the [versioned CBOR representation](15-pending-record-schema.md) refined by +[#343](https://github.com/cytechmobile/sendium/issues/343), under [#337](https://github.com/cytechmobile/sendium/issues/337). ## Flow Overview @@ -39,6 +41,8 @@ phases. Arrows show progression, not deletion of earlier records. multipart parts and in-flight sources, before HTTP 202 / SMPP success. When full, new admissions fail with **HTTP 503** or **SMPP `STATUS_THROTTLED`**. Existing work continues processing; cleanup frees space. Moving to a later stage does not free a Pending slot. An aggregate retains every original source ID. + Acknowledged duplicate multipart handles remain owned with their original ordinal until terminal completion + and required DLR handoff; their payloads are not routed independently. - **(2) Selected — `SelectedRouterStore`:** holds up to **1,000 queued or taken unassigned items**. Select up to **100 ready items/cycle**, limited by free slots, and route up to **100 takes/cycle** through routing rules. When full, additional ready work stays in Pending. Recording a destination @@ -73,16 +77,34 @@ phases. Arrows show progression, not deletion of earlier records. `sendium-core` supplies reusable contracts and implementations. `sendium-app` constructs and runs the standalone pipeline; importing the core library alone does not activate this storage assembly. +```mermaid +flowchart LR + ingress["HTTP / SMPP admission"] --> coordinator["OutboundCoordinator
ownership and transitions"] + pipeline["StandaloneOutboundPipeline
construction, polling, shutdown"] --> coordinator + pipeline --> dispatch["StandardOutboundDispatch
routing and provider executions"] + dispatch --> coordinator + dispatch --> routing["StandardRoutingManager
rules and filters"] + dispatch --> provider["SmppClientWorker
requests and callbacks"] + provider --> handoff["Tracker / DlrStorage
required handoff"] + handoff -->|Completion condition| coordinator + coordinator --> pending["PendingMessageStore
accepted sources"] + coordinator --> selected["SelectedRouterStore
selected backlog"] + coordinator --> routed["RoutedWorkStore
recorded destinations"] +``` + | Component | Responsibility | |---|---| | `MessageStorageProfile` / `StandaloneMessageStorage` | Validate startup selectors, produce the profile and report its non-durable guarantee. | | `StandaloneOutboundPipeline` | Start one shared coordinator; drive bounded selection, routing, dispatch, retries and drain-first shutdown. | | `KannelResource` / `SmppServerWorker` | Admit before HTTP 202 / SMPP success; retain original source identities through multipart preparation. | | `PendingMessageStore` / `MemoryPendingMessageStore` | Retain accepted sources, held parts and ready work through source completion. | +| `MultipartKey` / `MultipartMembership` | Match compatible parts and retain each source's assigned group, ordinal, absolute deadline and group creation order. | +| `PendingSourceRecord` / `PendingMessageData` / `PendingRecordCodec` | Explicit accepted-source representation and versioned Jackson CBOR codec; not a physical backend or automatic memory-store encoding. | +| `PendingMultipartRecovery` | Shared record-driven assembly/expiry entrypoint using bounded reads; backend/application startup integration is explicit. | | `SelectedRouterStore` / `MemorySelectedRouterStore` | Own bounded selection and unassigned routing work; retain selected bookkeeping after routing. | | `RoutedWorkStore` / `MemoryRoutedWorkStore` | Record one destination per selection and schedule its unfinished work by destination. | | `DefaultOutboundCoordinator` / `OutboundWork` | Enforce typed source/selection/work ownership, transitions, handoff-dependent completion and retryable cleanup. | -| `StandardRoutingManager` / `StandardOutboundDispatch` | Resolve routing rules, retain recording intents and schedule tracked provider attempts. | +| `StandardRoutingManager` / `StandardOutboundDispatch` | Resolve rules/filters, retain recording intents and schedule tracked provider attempts. | | `SmppClientWorker` / `SmppClientSessionHandler` / `CoordinatedSmppSubmission` | Prepare/send requests, process callbacks once and join part/replacement outcomes. | | `Tracker` / `StandardMessageTracker` | Signal successful required handoff; integrate with the independently configured DLR subsystem. | @@ -98,13 +120,13 @@ sequenceDiagram participant P as Provider execution and callbacks participant H as Required tracking / DLR handoff Client->>Ingress: Submit message - Ingress->>C: accept source or acceptHeld part - C-->>Ingress: Accepted ownership + Ingress->>C: accept source or acceptPart with timeout + C-->>Ingress: Confirmed source handle / multipart membership Ingress-->>Client: Success + gateway UUID D->>C: selectToRouter - select bounded ready work into router backlog D->>C: Take selected work for routing C-->>D: Selected payload + original source IDs - D->>R: Resolve one destination using routing rules + D->>R: Resolve one destination and apply routing filters R-->>D: Destination D->>C: recordToRouted - record choice and mark selected work routed D->>C: takeFromRouted - take assigned work for execution @@ -122,9 +144,10 @@ sequenceDiagram - **Identity:** HTTP/SMPP reuse each gateway UUID as its typed `SourceId`. `SelectionId` and `WorkId` identify later steps independently of mutable payloads and provider IDs. Return the actual take handle, with its updated payload, rather than a reconstructed wrapper; stale returns must not overwrite live work. -- **Incoming multipart:** `acceptHeld` retains each part before acknowledgement. The existing reassembler - makes an aggregate or expired individual parts ready using `makeHeldReady(originalSourceIds, payload)`. - This creates no new admission; publication retries do not re-acknowledge or reassemble the input. +- **Incoming multipart:** `acceptPart` retains each part and its group membership before acknowledgement. + The reassembler makes an aggregate or expired individual parts ready using `makeHeldReady(originalSourceIds, payload)`. + This creates no new admission; in-process publication retries do not re-acknowledge input or create + another source binding. - **Outgoing multipart:** provider requests and replacements share one routed parent and original source set. They are not copied-route branches. No source is removed until all required completion conditions succeed. - **Completion:** `complete(workId, stage)` can register an outstanding condition; it does not immediately @@ -133,7 +156,7 @@ sequenceDiagram - **DLR boundary:** completion waits for required handoff acceptance, not a later handset-delivery receipt. Receipt storage/delivery remains independent of outbound backend selection. -`StandardOutboundDispatch.routeSelected()` performs routing lookup. +`StandardOutboundDispatch.routeSelected()` performs routing lookup and filters. `OutboundCoordinator.recordToRouted(selected, destination)` records that already-chosen result and advances stage ownership; it does not choose the destination or send the message. `takeFromRouted(destination, timeout)` takes one assigned item without completing ownership; @@ -142,6 +165,73 @@ advances stage ownership; it does not choose the destination or send the message assembling or accepting new input. `selectToRouter(limit)` then selects eligible work into the router backlog and returns the newly staged count; it does not evaluate routing rules. +## Pending Sources and Multipart Groups + +`PendingMessageStore.admit` and `admitHeld` return the supplied stable `SourceId` only after acceptance. +`OutboundCoordinator.accept` and `acceptHeld` propagate that confirmed handle. A null or substituted +backend result is an ownership failure, never permission to acknowledge untracked work. `acceptPart` +similarly confirms the source in its returned `MultipartMembership`. `admitHeld` remains available for +generic held work without assigning it to a multipart group. + +`find(source)` returns an isolated copy of the **accepted** payload. It does not return the later routing +or provider-mutated execution message. `findMultipart(source)` returns immutable membership when present. +`sourcesAfter(cursor, limit)` enumerates immutable pages of at most `limit` handles in `UUID.compareTo` +order; start with a null cursor and continue after the last returned handle. Completion of that cursor +does not invalidate progress. This is a bounded read API, not selection. Recovery callers quiesce ingress +and processing; concurrent admission/completion is not a consistent snapshot. + +Every new client submission receives its own source ID, including a repeated multipart ordinal. +Only an **internal retry of the same retained source ID** preserves the first accepted value without +consuming another source slot. A successful earlier client response does not make a later submission +with the same content/reference a deduplicated internal retry. + +Multipart matching uses account/system, sender, recipient, reference width/value, declared total and +normalized UDH. Other header bytes are preserved, so different ports or headers do not share a group. +The parser supports one valid 8-bit or 16-bit concatenation element alongside other length-checked IEs. +Assign the part to the open, unexpired matching group; otherwise create a new group instance. +The first accepted source for each ordinal wins. Later sources report duplicate membership referencing +that original source and cannot replace its payload. The first accepted part fixes +the absolute deadline, and subsequent parts or timeout-configuration changes do not extend it. + +For **A=part1, B=part1, C=part2, D=part3**, assemble **A+C+D** and retain B's accepted handle with the +same group. All four submissions are acknowledged after admission. Completing the aggregate removes +all four source records after terminal processing and required handoff. B is never routed separately. +On expiry, each received original part publishes with its duplicate handles. Publication atomically +includes those handles even when their ingress notifications have not been processed. Mixing group +instances or publishing an incomplete assigned aggregate is rejected. + +The [schema reference](15-pending-record-schema.md) specifies all 54 accepted message fields, DLR return +metadata, multipart membership, extension types and validation rules. It does not serialize +`StandardMessage` directly or instantiate application classes from stored names. Normal memory +operation continues to use its existing snapshot policy; the standalone CBOR mapper is an explicit +library contract for backends, not a new serialization step on every memory admission. + +### Record-driven reconstruction + +`PendingMultipartRecovery` rebuilds runtime assembly from accepted records, saved assignments and +absolute deadlines. It validates bounded source pages and group-handle indexes before reporting ready. +Complete retained groups assemble in ordinal order; expired incomplete groups release received parts; +unexpired incomplete groups schedule only the time remaining. A restart at 12:00:50 with a saved +12:01:00 deadline leaves ten seconds, not another full timeout. + +Waiting groups retain handles/metadata, not a second payload backlog. `multipartSources(groupId)` reads +one group's retained handles, including duplicates and parts still awaiting ingress notification, +bounded by pending source capacity. There are at most 255 distinct ordinals. +Payloads are fetched for assembly or expiry. Closing recovery cancels its timers; source completion +still belongs to the coordinator after terminal processing and required DLR handoff. + +There is **no new durable checkpoint of a runtime assembly, release or publication decision**. +Pending-only recovery may repeat processing. Selection/destination decisions survive only under +their own configured stage guarantees. Within a running process, `makeHeldReady` retains the first +source-to-projection binding and prepared payload, making publication retries idempotent. + +`SmppServerWorker.reconstructPendingParts(pending, pageSize)` is an explicit pre-ingress startup hook +using its selected coordinator's publication boundary. It prevents worker startup/admission success +after failed reconstruction. It is **not** automatic restart recovery in the current standalone memory +profile. Opening/indexing a physical durable backend, invoking reconstruction before ingress/dispatch, +reconciling later stages and enabling a supported durable profile remain backend/application work in +#339 and later issues. The current coordinator still rejects durable stages. + ## Ownership and Capacity The stores are process-local objects, not three independent batches. The routed store contains queues @@ -171,12 +261,14 @@ A group of N accepted multipart sources uses N pending slots but one selected sl Partial terminal cleanup can retain routed records after source removal, so equal capacities do not eliminate cleanup-related backpressure or provide cross-store transactions. -Selection or returning an unroutable item alone does not count as productive cycle progress. -Shutdown prevents further cycle scheduling. Batch size is not TPS or provider +The processor schedules the next bounded cycle without a mandatory delay after successful routing +or provider scheduling. Idle, paused, or unsuccessful cycles back off for 100 milliseconds; +selection or returning an unroutable item alone does not count as progress. Shutdown prevents +further cycle scheduling. Batch size is not TPS or provider concurrency: attempts use the worker's shared rate limiter and a dispatcher-owned execution pool sized by its `threadCount`. At most that many preparation loops execute concurrently, taking waiting attempts from the worker FIFO. The default shared two-thread scheduler handles destination resolution -and timed retries; rate waits and SMPP sends run on the worker's pool. +and timed retries; rate waits, worker filters, and SMPP sends run on the worker's pool. Stopped-worker pools are retired during transition maintenance, and dispatcher close shuts down its worker pools after provider drain even when the timer scheduler is application-owned. Awaiting asynchronous outcomes does not hold a sending slot. Count limits do not bound message bytes. @@ -210,12 +302,15 @@ attempts, not either storage-return operation. | Admission capacity/unavailability | HTTP 503; SMPP capacity returns `STATUS_THROTTLED`. Failed admission is not acknowledged as success. | | Routing miss / nonterminal lookup failure | Use `requeueForRouting` with ownership retained. A terminal routing drop uses `discard`. | | Copied route / unsupported standalone destination | Reject before assignment, even if a copied rule resolves to one target. The existing legacy copied-routing API remains separate. | -| Destination-recording failure | Retry the retained original intent without repeating routing lookup. | +| Destination-recording failure | Retry the retained original intent without repeating lookup or routing filters. | | Required handoff failure | Retain ownership and retry the handoff, not an accepted provider request. | | Terminal cleanup failure | Retry unfinished cleanup without awaiting another successful handoff or redispatching provider work. | +| Uncertain source absence | Completion fails explicitly; only established absence on a ready backend permits idempotent success. | +| Corrupt/unknown-version recovery input | Fail reconstruction/readiness explicitly; do not skip the source or treat it as completed. | Each provider response is claimed once; duplicate callbacks cannot create another replacement or -complete unrelated sources. Admission deduplication applies while ownership is retained, not forever. +complete unrelated sources. Same-source-ID admission idempotence applies while ownership is retained, +not forever; new client submissions remain independent. Runtime worker removal's legacy queue return does not migrate coordinator-owned parent records. Provider preparation uses a per-worker FIFO of already-owned runtime attempts. Up to the worker's @@ -288,8 +383,17 @@ existing router/worker startup. A failing coordinated admission never falls back Future profiles are not selectable today. Memory remains the non-durable default; planned file profiles are opt-in. Durable recovery must reconcile identities and resume the latest recorded stage without scheduling earlier retained records independently. It requires surviving storage and remains at least -once: provider submission before recorded completion may repeat. Physical backends, codecs and durable -provider-outcome checkpoints belong to subsequent issues. +once: provider submission before recorded completion may repeat. The CBOR source codec is implemented; +physical backends and durable later-stage implementations belong to subsequent issues. Durable provider +outcome/retry checkpoints remain outside this delivery. + +### Delivery and rollback boundary + +#343 adds pending contracts, source representation, shared runtime reconstruction and behavioral proofs. +It adds no spool directory/publication scheme, PostgreSQL outbound schema, durable profile or durable-data +migration. Its dependent commits can be reverted together, in reverse order, to the working #338 memory +baseline while retaining that baseline's performance fixes. This code-only rollback does not recover +unfinished memory work across a restart. Existing PostgreSQL DLR storage remains independently owned. ## Embedding and API Reference @@ -306,6 +410,8 @@ Detailed operation, idempotence and failure rules are defined in the tracked Jav - [OutboundCoordinator](../sendium-core/src/main/java/gr/cytech/sendium/core/outbound/OutboundCoordinator.java) and [OutboundWork](../sendium-core/src/main/java/gr/cytech/sendium/core/outbound/OutboundWork.java) - [PendingMessageStore](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/PendingMessageStore.java) +- [PendingRecordCodec](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodec.java) and [pending record schema](15-pending-record-schema.md) +- [PendingMultipartRecovery](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecovery.java) - [SelectedRouterStore](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/SelectedRouterStore.java) - [RoutedWorkStore](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/RoutedWorkStore.java) - [OutboundStage](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/OutboundStage.java) and [OutboundStorageException](../sendium-core/src/main/java/gr/cytech/sendium/core/storage/OutboundStorageException.java) @@ -316,3 +422,9 @@ shutdown ordering and startup rejection. Provider transport/handoffs use control external provider. Key tests are [StandaloneOutboundPipelineTest](../sendium-app/src/test/java/gr/cytech/sendium/app/storage/StandaloneOutboundPipelineTest.java), [SmppClientWorkerTest](../sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java) and [OutboundEmbeddedLifecycleTest](../sendium-core/src/test/java/gr/cytech/sendium/core/outbound/consumer/OutboundEmbeddedLifecycleTest.java). +Pending-specific proofs are [MemoryPendingMessageStoreContractTest](../sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/MemoryPendingMessageStoreContractTest.java), +[PendingRecordMappingTest](../sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordMappingTest.java), +[PendingRecordCodecTest](../sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodecTest.java), +[PendingCompletionFailureTest](../sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingCompletionFailureTest.java) and +[PendingMultipartRecoveryTest](../sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecoveryTest.java). +Codec-backed reconstruction fixtures are not physical file-publication or forced-process restart tests. diff --git a/docs/15-pending-record-schema.md b/docs/15-pending-record-schema.md new file mode 100644 index 00000000..f1201a70 --- /dev/null +++ b/docs/15-pending-record-schema.md @@ -0,0 +1,314 @@ +# Pending accepted-source record — CBOR schema + +[Outbound storage lifecycle](14-outbound-storage.md) · [Documentation map](DocumentationMap.md) + +This documents the explicit pending-source representation introduced by #343. +It is not Java serialization, a serialization of `StandardMessage`, a file layout, +or a supported durable backend. The standalone runtime still supports only +`memory/memory/memory`. Filesystem admission/restart recovery belongs to #339. + +## API and ownership + +`PendingSourceRecord` contains the existing typed `OutboundWork.SourceId`, an +accepted disposition (`READY` or `HELD`), `PendingMessageData`, and optional +`MultipartMembership`. The disposition is the original admission disposition, +not a checkpoint of subsequent router/routed ownership. +`PendingMessageData.capture(StandardMessage)` captures the accepted values using +an explicit mapping; `restore()` returns a new mutable runtime message each time. +Maps, lists and byte arrays are detached from both input and restored messages. +The source ID must equal the gateway UUID in `serial` (canonical UUID syntax, +case-insensitive). Custom `StandardMessage` subclasses are not supported by this +mapper. No existing memory snapshot or ingress behavior is changed by the codec. + +`PendingRecordCodec.encode(record)` returns CBOR bytes. +`PendingRecordCodec.decode(bytes)` validates one complete envelope and returns a +record. The codec has no storage, acknowledgement, routing, or lifecycle side +effects. Later admission integration must fail before client success if a selected +durable representation cannot preserve the accepted data. No codec integration +or new backend profile is enabled by the record/codec work. + +## Version 1 envelope + +The top-level value is a CBOR map with text keys, emitted in this order: + +| Field | Type | Required meaning | +|---|---|---| +| `schemaVersion` | Positive signed 32-bit integer | `1` | +| `sourceId` | UUID text | Existing gateway/source UUID | +| `disposition` | Text enum | `READY` or `HELD` | +| `message` | Map | Explicit message fields below | +| `multipart` | Null or map | Recorded multipart membership below; null for ordinary sources | + +All documented fields are present, including fields whose value is null. +Missing fields are corruption, not an instruction to use Java constructor +defaults. Null is permitted for message text/reference/container fields as listed +below; it is not interchangeable with empty text, an empty collection, zero or +false. Primitive fields cannot be null. Numeric range/type checks do not coerce +strings, booleans or fractional numbers into integer fields. `dcs` uses the signed +Java byte representation (-128 through 127), preserving all eight bits. + +Version 1 is the explicit accepted-source schema implemented by #343. The earlier +complete-source development commit was not a released durable format. The +`multipart` key is required (null for ordinary sources). Intermediate development +records without this key or the required membership `originalSourceId` are rejected, rather than silently interpreted as a complete +durable schema. Saved membership does not itself implement process-restart recovery; +shared reconstruction is described below, and a physical backend follows in #339. + +## Multipart admission and membership + +`PendingMessageStore.admitPart(source, message, timeout)` atomically retains the +held source and assigns its group before returning `MultipartMembership`. +`OutboundCoordinator.acceptPart` exposes that confirmed result to SMPP ingress; +`findMultipart(source)` retrieves the immutable membership through the pending port. +Plain `admitHeld` remains available for held sources without a multipart assignment. + +Each **new source ID** is accepted and retained. Join the open, unexpired matching +group or create a fresh group UUID. The first accepted source for an ordinal wins; +later sources reference it through `originalSourceId` and report `duplicate() == true`. +Their payloads never replace the original or route independently. The saved `groupOrder` is carried in every member record, +so a later recovery does not have to guess the order of groups with equal deadlines. +Internal retries using the same retained source ID return +its original membership and never overwrite its accepted payload. A source admitted +without multipart membership cannot be converted into one by retrying `admitPart`. + +The matching key has account ID, system ID, sender, recipient, reference width/value, +declared total and normalized UDH. The parser checks the entire hex-encoded header: +UDHL must match its byte count; each information element must fit; exactly one +valid 8-bit or 16-bit concatenation element must exist; total is 1–255 and ordinal +is 1–total. Concatenation may appear before or after other IEs. Normalization uses +uppercase hex and replaces only the concatenation ordinal byte with zero. All +other bytes, including IE order and port information, are preserved; reordered IEs +are not asserted equivalent by this representation. + +The first accepted part fixes an absolute deadline using the backend's clock and +the configured timeout. Later parts do not extend it, even if the configured timeout +changes. Expiry is inclusive: at the deadline, the group cannot receive new parts. +Complete groups, groups published for processing, and groups whose sources begin +completion also stop accepting new members. This does not remove retained sources +or their saved membership; source removal still requires terminal processing and +the required DLR handoff. + +Example: A=part1, B=part1, C=part2, D=part3 produce one group with four accepted handles. +Assembly uses A+C+D; B's membership references A. Terminal completion includes A/B/C/D. +If incomplete at expiry, A publishes with B's handle and the other received original +ordinals publish independently. Publication includes duplicate handles atomically, even +before their ingress notifications are processed. Existing non-coordinated legacy +entrypoints retain their first-ordinal-wins behavior. + +| Membership field | Stored type / meaning | +|---|---| +| `sourceId` | Canonical UUID text; original part UUID, must match envelope/source serial | +| `originalSourceId` | Canonical UUID text; first accepted source for this ordinal; equals `sourceId` for an original, differs for a duplicate | +| `groupId` | Canonical UUID text; stable assigned group instance | +| `ordinal` | Integer in 1–`key.total` | +| `deadline` | ISO-8601 UTC `Instant` text, preserving nanoseconds | +| `groupOrder` | Non-negative int64; stable group creation order within the backend's retained history | +| `key` | Explicit identity map below | + +The key map contains required nullable text fields `accountId`, `systemId`, `sender` +and `recipient`; integer `referenceBits` (8 or 16), `reference` (0–255 or 0–65535 +respectively), `total` (1–255); and required non-null text `normalizedUdh`. +Null remains distinct from empty text for matching identity. Non-null membership +requires `HELD` original disposition. Its key and ordinal must match the accepted +payload's parsed UDH and addresses; mismatches are corruption, not a request to +reassign the source on decode. Decode never calculates a new group UUID or deadline. + +## Reconstructing runtime assembly + +`PendingMultipartRecovery` reads a quiescent pending store through bounded +`sourcesAfter` pages. It indexes only source handles and multipart metadata, +validating the complete scan before publishing work or declaring recovery ready. +Group members must agree on their key, group UUID, creation order and deadline. +Each ordinal has one original source; duplicates must reference that retained original +in the same group and ordinal. Missing or conflicting originals fail reconstruction. + +- A complete retained group is assembled in ordinal order using detached accepted + payloads, retaining the original source IDs for completion and DLR handling. +- An incomplete expired group releases its original parts independently, each owning its duplicate handles. +- An incomplete unexpired group schedules its original deadline. Reconstructing + at 12:00:50 with deadline 12:01:00 leaves ten seconds, regardless of downtime. +- `multipartSources(groupId)` returns only that group's retained handles, including + duplicates, bounded by pending source capacity. It includes accepted parts whose ingress + notifications have not been processed. Payloads are fetched only on assembly or + expiry; there is no second cache containing the entire backlog's message bodies. +- A new part notification carries a source handle. Current membership and payload + remain authoritative in pending storage, including after reconstruction. + +There is **no durable checkpoint of an earlier assembly/publication/release +decision**. Accepted-source CBOR, membership and deadlines are the reconstruction +inputs. Pending-only recovery can repeat runtime processing; selected batches and +destinations survive only under their independently configured stage guarantees. +Publication retries in a running process use the existing first-binding/idempotence +contract of `makeHeldReady`. Transient timer-publication failures retry after one +second without accepting new sources or making provider attempts. Corrupt recovery +input fails explicitly, leaving accepted ownership intact. + +`SmppServerWorker.reconstructPendingParts(pending, pageSize)` is the startup +entrypoint after selecting its coordinator and opening the owning pending store, +before starting ingress/dispatch or accepting submissions. It uses the existing +coordinator publication boundary. Failed reconstruction prevents worker startup +and success acknowledgements; it does not fall back to legacy processing. Existing +non-record-driven entrypoints remain supported. Reassembly timeout reconfiguration +affects new admissions, not saved group deadlines or the active recovery component. + +The application owns the scheduler and pending store; closing the recovery component +cancels its own timers only. The handler owns and stops its scheduler as before. +Actual durable backend opening, accepted-record indexing, stage reconciliation and +profile enablement remain with the backend/application assembly in #339 and later +stage implementations. This entrypoint does not remove the coordinator's current +guard rejecting unsupported durable profiles. The existing memory backend still +loses unfinished records when closed or restarted. + +Recovery tests use copied codec-backed source records and a controlled clock/timer. +They verify reconstruction, not physical file publication or forced-process restart +durability. Full group payloads may be loaded while assembling one message (at most +255 parts); metadata indices can grow with the count of incomplete groups. Bounded +recovery access is not a claim of constant memory for those indices. + +## Explicit message field inventory + +Every public **instance** field of `StandardMessage` is preserved at capture time. +None is implicitly omitted, derived from a new wall-clock time, or obtained by +reflective message serialization. Fields that later change during routing or +provider processing retain their **accepted** value here: this is not a checkpoint +of later runtime execution. Constants, `fieldMap`, the logger, methods and Java +class identity are not message data and are not encoded. + +The parameterized `PendingRecordMappingTest#roundTripsEveryPreservedField` discovers +public instance fields for test coverage only. A new field without an explicit +capture/restore mapping fails the test instead of silently inheriting serialization. + +| Runtime field | Schema field | Stored type / reason | +|---|---|---| +| `from` | `from` | Nullable text; sender | +| `to` | `to` | Nullable text; recipient | +| `timestamp` | `timestamp` | Nullable text; original timestamp representation | +| `type` | `type` | Int32; message type | +| `body` | `body` | Nullable text; accepted body | +| `serial` | `serial` | UUID text; gateway identity | +| `message_center` | `messageCenter` | Nullable text; message center/input | +| `owner_id` | `ownerId` | Nullable text; account identity | +| `systemId` | `systemId` | Nullable text; system identity | +| `priority` | `priority` | Int32; selection/routing input | +| `acked` | `acked` | Boolean; accepted acknowledgement-related value | +| `msgId` | `msgId` | Int32; accepted message field | +| `expr` | `expr` | Int64; existing eligibility/order input | +| `rtxCnt` | `rtxCnt` | Int32; initial accepted value, not a durable retry checkpoint | +| `binbody` | `binbody` | Nullable text; unchanged existing binary-body representation | +| `binheader` | `binheader` | Nullable text; unchanged UDH representation | +| `flash` | `flash` | Boolean; message formatting | +| `mclass` | `mclass` | Int32; message class | +| `ttl` | `ttl` | Int32; validity input | +| `ddt` | `ddt` | Int32; deferred-delivery input | +| `dcs` | `dcs` | Signed int8; data coding | +| `onetwork` | `onetwork` | Int32; original network/routing input | +| `cnetwork` | `cnetwork` | Int32; current network at acceptance | +| `ingateway` | `ingateway` | Nullable text; ingress identity | +| `outgateway` | `outgateway` | Nullable text; accepted routing/worker input | +| `state` | `state` | Int32; accepted state, not a provider-outcome checkpoint | +| `cost` | `cost` | Int64; accepted processing input | +| `ctstamp` | `ctstamp` | Int64; accepted creation timestamp | +| `sutstamp` | `sutstamp` | Int64; accepted submit timestamp | +| `errcode` | `errcode` | Nullable text; accepted error-related value | +| `extrid` | `extrid` | Nullable text; external identity | +| `nextTarget` | `nextTarget` | Nullable text; routing input | +| `field1` | `field1` | Tagged extension; includes normal HTTP protocol ID input | +| `field2` | `field2` | Tagged extension; includes normal HTTP alternate DCS input | +| `field3` | `field3` | Tagged extension; includes normal HTTP reply-path input | +| `field4` | `field4` | Tagged extension; includes normal HTTP billing-info input | +| `field5` | `field5` | Tagged extension; accepted input only | +| `field6` | `field6` | Tagged extension; accepted input only | +| `field7` | `field7` | Tagged extension; accepted input only | +| `field8` | `field8` | Tagged extension; accepted input only | +| `field9` | `field9` | Tagged extension; accepted input only | +| `field10` | `field10` | Tagged extension; no later filter-phase marker synthesized | +| `paid` | `paid` | Boolean; accepted processing input | +| `metadata` | `metadata` | Nullable text; opaque metadata, not parsed/reformatted | +| `smsCnt` | `smsCnt` | Int32; initial accepted count | +| `smsSubmitCnt` | `smsSubmitCnt` | Int32; initial accepted count | +| `attrs` | `attrs` | Null or tagged string-keyed map; DLR/request and processing inputs | +| `reassembledParts` | `reassembledParts` | Null or array of nullable text; original source identities if present | +| `dlrReturnMetadata` | `dlrReturnMetadata` | Null or explicit DLR map below, despite runtime `@JsonIgnore` | +| `tlvs` | `tlvs` | Null or sorted text-keyed map of nullable text; SMPP TLVs | +| `dispatchType` | `dispatchType` | Int32; dispatch input | +| `hlrRoute` | `hlrRoute` | Nullable text; routing input | +| `marginPercentage` | `marginPercentage` | Finite float64; accepted processing input | +| `marginStatus` | `marginStatus` | Null or named known enum (`NOT_CALCULATED`, `KNOWN`, `UNKNOWN`) | + +Preserving an initial count, state or routing field does not authorize persisting +later retries/outcomes or routing-time filter mutations as source state. Recovering +only the pending stage reruns selection/routing, as defined by the epic. + +### DLR return map + +`channel` is required non-null enum text (`HTTP` or `SMPP`). `accountId`, `systemId`, +`sourceAddress`, `destinationAddress` and `forwardDlrUrl` are required keys with +nullable text values. They preserve the corresponding `DlrReturnMetadata` record +components verbatim. Request flags in the message's normal protocol fields/attrs +are preserved by the message mapping; no DLR backend is serialized here. + +## Extension vocabulary + +An extension is either CBOR null, or a map containing `type` and `value`: + +| `type` | `value` | Restored Java value | +|---|---|---| +| `string` | Non-null text | `String` | +| `boolean` | Boolean | `Boolean` | +| `int8` | Integer in signed 8-bit range | `Byte` | +| `int16` | Integer in signed 16-bit range | `Short` | +| `int32` | Integer in signed 32-bit range | `Integer` | +| `int64` | Integer in signed 64-bit range | `Long` | +| `float32` | Finite number exactly representable as float32 | `Float` | +| `float64` | Finite number | `Double` | +| `bytes` | Native CBOR byte string, not base64 text | Detached `byte[]` | +| `list` | Array of extensions | Detached `ArrayList` | +| `map` | Text-keyed map of extensions | Detached map | + +Map keys are emitted in Java `String` natural order (at every nesting level). +Array order is retained. Scalar tags distinguish, for example, integer `1` from +long `1L` even if CBOR's preferred integer representation uses the same width. +This is deterministic encoding by this schema/codec, not a claim of implementing +every RFC canonical-CBOR option or preserving original collection classes. + +Example in diagnostic JSON notation (actual bytes are CBOR): + +```json +{"type":"map","value":{"attemptLimit":{"type":"int32","value":3},"campaign":{"type":"string","value":"summer"}}} +``` + +Unsupported values include arbitrary application objects/enums, characters, +non-string map keys, other array types, NaN/infinities, cyclic containers and +nesting beyond 64 container levels (root is level 0; levels 0–64 are supported). +Shared acyclic containers may occur in several places and are copied as values. +The codec rejects unsupported data explicitly; it never substitutes `toString()` +or skips an unsupported entry. Custom before-insert filter object support is +deferred. Existing in-memory snapshot rules remain unchanged. + +## Validation, compatibility and failures + +- Positive unknown schema versions produce `PendingRecordException` with + `kind() == UNSUPPORTED_VERSION` and the existing storage reason `UNSUPPORTED`. +- Malformed/truncated CBOR, missing required fields, invalid primitive types or + ranges, unknown enum/type tags, mismatched gateway/source UUIDs, duplicate map + keys, invalid extension values and trailing CBOR values produce + `kind() == CORRUPT_RECORD` with storage reason `UNAVAILABLE`. A corrupt source + cannot be treated as absent. These codec-specific details preserve the existing + protocol-facing storage reason vocabulary. +- Encode/capture of unsupported application values produces storage reason + `UNSUPPORTED`. All codec errors identify pending stage and codec context, with + fixed diagnostics and no parser-cause chain containing payload values. +- Unknown optional keys within version 1 are ignored. They cannot change the + meaning of required fields; semantic changes require a new version. Unknown + fields are not promised to survive decode/re-encode. +- No Java class names are interpreted, no Jackson default typing is enabled, + and the runtime `StandardMessage` JSON contract is untouched. +- The parser limits total CBOR nesting to 256, sufficient for this tagged + extension vocabulary's 64-level limit and envelope overhead. +- There is no earlier durable pending schema to migrate in this delivery. + Backward migration/version adapters are not implemented speculatively. + +The codec detects structural and semantic corruption. It is not a checksum or +an authenticated format: arbitrary bit changes that still form valid supported +values cannot all be detected by the codec. File publication/integrity policy is +owned by the physical backend implementation. diff --git a/docs/DocumentationMap.md b/docs/DocumentationMap.md index 8c74c4db..ee295bcb 100644 --- a/docs/DocumentationMap.md +++ b/docs/DocumentationMap.md @@ -23,6 +23,7 @@ Sendium is an open-source, headless SMS gateway for high-throughput messaging. I | Review current features and roadmap | [12. Features And Roadmap](12-features-roadmap.md) | | Configure PostgreSQL DLR persistence and review durability | [13. DLR Persistence](13-dlr-persistence.md) | | Understand outbound storage and extension contracts | [14. Outbound Message Storage](14-outbound-storage.md) | +| Implement pending-source encoding and reconstruction | [15. Pending Record Schema](15-pending-record-schema.md) | | Contribute code or docs | [Contributing](../.github/CONTRIBUTING.md) | ## Core Concepts @@ -65,6 +66,7 @@ Sendium expects these files in the configured `conf` directory. The Docker quick | [12. Features And Roadmap](12-features-roadmap.md) | Current product capabilities, planned roadmap phases, and related feature documentation. | | [13. DLR Persistence](13-dlr-persistence.md) | PostgreSQL setup, retention, restart guarantees, and durability limits. | | [14. Outbound Message Storage](14-outbound-storage.md) | Diagram-led lifecycle guide, class responsibilities, capacity bounds, retries, shutdown, profiles, and extension contracts. | +| [15. Pending Record Schema](15-pending-record-schema.md) | Versioned CBOR source fields, multipart membership/deadlines, supported extensions, validation and record-driven reconstruction boundaries. | | [Kannel migration converter](https://cytechmobile.github.io/sendium/kannelconverter/) | Browser-only helper for turning a legacy `kannel.conf` into Sendium starter files. | ## API Discovery @@ -77,7 +79,7 @@ When Sendium is running, the HTTP API can be inspected through: | `/swagger-ui` | Interactive Swagger UI. | | `/openapi.json` | OpenAPI specification. | | `/q/metrics` | Prometheus-compatible Micrometer metrics endpoint. | -| `/q/health/ready` | Readiness status, including PostgreSQL DLR storage availability. | +| `/q/health/ready` | Readiness status, including configured DLR storage and the standalone outbound pipeline. | ## Community And Project Files diff --git a/sendium-app/src/test/java/gr/cytech/sendium/app/storage/MessageLifecycleHttpTest.java b/sendium-app/src/test/java/gr/cytech/sendium/app/storage/MessageLifecycleHttpTest.java index 6d37bc9a..3fca5a83 100644 --- a/sendium-app/src/test/java/gr/cytech/sendium/app/storage/MessageLifecycleHttpTest.java +++ b/sendium-app/src/test/java/gr/cytech/sendium/app/storage/MessageLifecycleHttpTest.java @@ -7,7 +7,11 @@ import gr.cytech.sendium.core.message.StandardMessage; import gr.cytech.sendium.core.outbound.DefaultOutboundCoordinator; import gr.cytech.sendium.core.outbound.OutboundCoordinator; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; import gr.cytech.sendium.core.queue.InMemoryQueueProvider; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.PendingMessageStore; import gr.cytech.sendium.core.storage.memory.MemoryPendingMessageStore; import gr.cytech.sendium.core.storage.memory.MemoryRoutedWorkStore; import gr.cytech.sendium.core.storage.memory.MemorySelectedRouterStore; @@ -19,7 +23,10 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.net.URI; import java.net.http.HttpClient; @@ -32,6 +39,11 @@ import java.util.concurrent.CompletableFuture; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; class MessageLifecycleHttpTest { @RegisterExtension @@ -52,14 +64,25 @@ class MessageLifecycleHttpTest { @Inject InMemoryQueueProvider legacy; + @Inject + PendingMessageStore pending; + + @AfterEach + void clearAdmissionFaults() { + reset(pending); + } + @Test - void httpUsesTheExplicitCoordinatorBeanAndReturnsBackpressureAtCapacity() throws Exception { + void successReferencesAcceptedHandle() throws Exception { URI requestUri = URI.create(endpoint + "?username=http-user&password=secret&from=Sender&to=306900000001&text=Hello" + "&dlr-url=https%3A%2F%2Fcallback.test%2Fdlr"); try (HttpClient client = HttpClient.newHttpClient()) { var request = HttpRequest.newBuilder(requestUri).timeout(Duration.ofSeconds(5)).GET().build(); var accepted = client.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(accepted.statusCode()).isEqualTo(202); + var retained = pending.find(new SourceId(UUID.fromString(accepted.body()))).orElseThrow(); + assertThat(retained.serial).isEqualTo(accepted.body()); + assertThat(retained.body).isEqualTo("Hello"); var full = client.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(full.statusCode()).isEqualTo(503); assertThat(legacy.getRouterQueue().isEmpty()).isTrue(); @@ -75,12 +98,54 @@ void httpUsesTheExplicitCoordinatorBeanAndReturnsBackpressureAtCapacity() throws } } + @ParameterizedTest + @ValueSource(strings = {"UNAVAILABLE", "CAPACITY_EXCEEDED", "OWNERSHIP_CONFLICT", "UNSUPPORTED", "INVALID_TRANSITION"}) + void rejectsPendingAdmissionFailures(String reasonName) throws Exception { + // Strings cross QuarkusExtensionTest's class-loader boundary; application enum instances do not. + var reason = OutboundStorageException.Reason.valueOf(reasonName); + doThrow(new OutboundStorageException(OutboundStage.Role.PENDING, reason, "private storage details")) + .when(pending).admit(any(), any()); + try (HttpClient client = HttpClient.newHttpClient()) { + var request = HttpRequest.newBuilder(URI.create(endpoint + + "?username=http-user&password=secret&from=Sender&to=306900000001&text=Hello")) + .timeout(Duration.ofSeconds(5)).GET().build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int expected = switch (reason) { + case UNSUPPORTED -> 400; + case INVALID_TRANSITION -> 500; + default -> 503; + }; + assertThat(response.statusCode()).isEqualTo(expected); + assertThat(response.body()).doesNotContain("private storage details"); + assertThat(pending.sourcesAfter(null, 1)).isEmpty(); + assertThat(legacy.getRouterQueue().isEmpty()).isTrue(); + } + } + + @Test + void unconfirmedPendingHandleIsNeverAcknowledged() throws Exception { + doReturn(null).when(pending).admit(any(), any()); + try (HttpClient client = HttpClient.newHttpClient()) { + var request = HttpRequest.newBuilder(URI.create(endpoint + + "?username=http-user&password=secret&from=Sender&to=306900000001&text=Hello")) + .timeout(Duration.ofSeconds(5)).GET().build(); + assertThat(client.send(request, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(503); + assertThat(pending.sourcesAfter(null, 1)).isEmpty(); + assertThat(legacy.getRouterQueue().isEmpty()).isTrue(); + } + } + @Singleton public static class Beans { @Produces @Singleton - OutboundCoordinator coordinator() { - var pending = new MemoryPendingMessageStore(1, Beans::snapshot); + MemoryPendingMessageStore pending() { + return spy(new MemoryPendingMessageStore(1, Beans::snapshot)); + } + + @Produces + @Singleton + OutboundCoordinator coordinator(MemoryPendingMessageStore pending) { var router = new MemorySelectedRouterStore<>(pending, 1); var routed = new MemoryRoutedWorkStore(1, Beans::snapshot); var coordinator = new DefaultOutboundCoordinator<>(pending, router, routed); diff --git a/sendium-app/src/test/java/gr/cytech/sendium/app/storage/StandaloneMessageSnapshotTest.java b/sendium-app/src/test/java/gr/cytech/sendium/app/storage/StandaloneMessageSnapshotTest.java index 89349250..a5659404 100644 --- a/sendium-app/src/test/java/gr/cytech/sendium/app/storage/StandaloneMessageSnapshotTest.java +++ b/sendium-app/src/test/java/gr/cytech/sendium/app/storage/StandaloneMessageSnapshotTest.java @@ -1,16 +1,73 @@ package gr.cytech.sendium.app.storage; +import gr.cytech.sendium.core.message.DlrReturnMetadata; import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.memory.MemoryPendingMessageStore; +import gr.cytech.sendium.core.storage.pending.PendingMessageData; +import gr.cytech.sendium.core.storage.pending.PendingRecordCodec; +import gr.cytech.sendium.core.storage.pending.PendingSourceRecord; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class StandaloneMessageSnapshotTest { + @Test + void normalProtocolRecordPreservesProcessingInputs() { + for (var metadata : List.of(DlrReturnMetadata.http("account", "sender", "recipient", "https://callback.test/dlr"), + DlrReturnMetadata.smpp("account", "system", "sender", "recipient"))) { + var message = new StandardMessage(); + SourceId source = new SourceId(UUID.randomUUID()); + message.serial = source.value().toString(); + message.owner_id = "account"; + message.systemId = "system"; + message.from = "sender"; + message.to = "recipient"; + message.body = "Hello"; + message.priority = 3; + message.ttl = 120; + message.ddt = 5; + message.dcs = StandardMessage.DCS_16BIT; + message.nextTarget = "provider"; + message.field1 = 7; + message.field2 = 8; + message.field3 = 1; + message.field4 = "billing-info"; + message.acked = true; + message.dlrReturnMetadata = metadata; + message.tlvs = new HashMap<>(Map.of("0427", "1")); + message.attrs = new HashMap<>(Map.of("request", "original")); + + try (PendingMessageStore pending = new MemoryPendingMessageStore<>(1, StandaloneMessageSnapshot::copy)) { + pending.open(); + SourceId accepted = pending.admit(source, message); + var retained = pending.find(accepted).orElseThrow(); + message.field4 = "changed after admission"; + message.putAttr("routing-only", "not accepted input"); + message.tlvs.clear(); + var codec = new PendingRecordCodec(); + var record = new PendingSourceRecord(accepted, PendingSourceRecord.Disposition.READY, PendingMessageData.capture(retained)); + StandardMessage restored = codec.decode(codec.encode(record)).message().restore(); + assertThat(restored).usingRecursiveComparison().isEqualTo(retained); + assertThat(restored.field1).isEqualTo(7); + assertThat(restored.field2).isEqualTo(8); + assertThat(restored.field3).isEqualTo(1); + assertThat(restored.field4).isEqualTo("billing-info"); + assertThat(restored.dlrReturnMetadata).isEqualTo(metadata); + assertThat(restored.tlvs).containsEntry("0427", "1"); + assertThat(restored.attrs).containsExactlyEntriesOf(Map.of("request", "original")); + } + } + } + @Test void snapshotDetachesProtocolCollectionsAndMutableAttributeValues() { var message = new StandardMessage(); diff --git a/sendium-core/pom.xml b/sendium-core/pom.xml index 69fcb659..c4061450 100644 --- a/sendium-core/pom.xml +++ b/sendium-core/pom.xml @@ -70,6 +70,10 @@ com.fasterxml.jackson.dataformat jackson-dataformat-yaml + + com.fasterxml.jackson.dataformat + jackson-dataformat-cbor + io.quarkus quarkus-junit diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java b/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java index 258e41a0..67248ca8 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java @@ -217,17 +217,20 @@ public Response receiveSms( if (msg.acked) { msg.dlrReturnMetadata = DlrReturnMetadata.http(usr, msg.from, msg.to, dlrUrl); } + String acceptedMessageId; if (outboundCoordinators.isUnsatisfied()) { queueProvider.getRouterQueue().enqueue(msg); + acceptedMessageId = msg.serial; } else { - outboundCoordinators.get().accept(new SourceId(gatewayMessageId), msg); + SourceId accepted = outboundCoordinators.get().accept(new SourceId(gatewayMessageId), msg); + acceptedMessageId = accepted.value().toString(); } if (MessageTrace.shouldLog(configurationHandler, MessageTrace.EVENT_ACCEPTED)) { logger.info("message.accepted ingress=http {}", MessageTrace.identifiers(msg)); } return Response.status(Response.Status.ACCEPTED) - .entity(msg.serial) + .entity(acceptedMessageId) .build(); } catch (OutboundStorageException e) { diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinator.java b/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinator.java index 2cc09cf1..d7007c83 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinator.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinator.java @@ -15,6 +15,7 @@ import gr.cytech.sendium.core.storage.PendingMessageStore; import gr.cytech.sendium.core.storage.RoutedWorkStore; import gr.cytech.sendium.core.storage.SelectedRouterStore; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; import java.time.Duration; import java.util.ArrayList; @@ -122,32 +123,58 @@ public void beginShutdown() { } @Override - public void accept(SourceId source, M message) { - accept(source, message, false); + public SourceId accept(SourceId source, M message) { + return accept(source, message, false); } - private void accept(SourceId source, M message, boolean held) { + private SourceId accept(SourceId source, M message, boolean held) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(message, "message"); lock.lock(); try { requireRunning(Role.PENDING); if (sourceSelections.containsKey(source)) { - return; + return source; } - if (held) { - pending.admitHeld(source, message); - } else { - pending.admit(source, message); + SourceId accepted = held ? pending.admitHeld(source, message) : pending.admit(source, message); + if (!source.equals(accepted)) { + throw new OutboundStorageException(Role.PENDING, OutboundStorageException.Reason.OWNERSHIP_CONFLICT, + "Pending admission did not confirm the requested source handle"); } + return accepted; } finally { lock.unlock(); } } @Override - public void acceptHeld(SourceId source, M message) { - accept(source, message, true); + public SourceId acceptHeld(SourceId source, M message) { + return accept(source, message, true); + } + + @Override + public MultipartMembership acceptPart(SourceId source, M message, Duration timeout) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(message, "message"); + Objects.requireNonNull(timeout, "timeout"); + lock.lock(); + try { + requireRunning(Role.PENDING); + MultipartMembership accepted; + if (sourceSelections.containsKey(source)) { + accepted = pending.findMultipart(source) + .orElseThrow(() -> invalid(Role.PENDING, "Multipart source is no longer retained")); + } else { + accepted = pending.admitPart(source, message, timeout); + } + if (accepted == null || !source.equals(accepted.source())) { + throw new OutboundStorageException(Role.PENDING, OutboundStorageException.Reason.OWNERSHIP_CONFLICT, + "Pending admission did not confirm multipart source membership"); + } + return accepted; + } finally { + lock.unlock(); + } } @Override diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/OutboundCoordinator.java b/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/OutboundCoordinator.java index 35186325..4ba26f35 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/OutboundCoordinator.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/outbound/OutboundCoordinator.java @@ -7,6 +7,7 @@ import gr.cytech.sendium.core.outbound.OutboundWork.SelectionId; import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; import gr.cytech.sendium.core.outbound.OutboundWork.WorkId; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; import java.time.Duration; import java.util.Optional; @@ -35,11 +36,14 @@ public interface OutboundCoordinator extends AutoClos */ void beginShutdown(); - /** Accepts source ownership and makes the message ready for selection according to the store policy. */ - void accept(SourceId source, M message); + /** Accepts ready source ownership and returns the same confirmed handle before protocol success; null is never acceptance. */ + SourceId accept(SourceId source, M message); - /** Accepts source ownership without making this source routable yet. */ - void acceptHeld(SourceId source, M message); + /** Accepts ownership without making the source routable; returns the same confirmed handle as accept. */ + SourceId acceptHeld(SourceId source, M message); + + /** Accepts a multipart source with stable group membership before protocol success. */ + MultipartMembership acceptPart(SourceId source, M message, Duration timeout); /** * Makes already-accepted held sources available to bounded selection using one prepared execution message. diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/MessagePartsHandler.java b/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/MessagePartsHandler.java index 63622466..bc2fc854 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/MessagePartsHandler.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/MessagePartsHandler.java @@ -1,10 +1,18 @@ package gr.cytech.sendium.core.smpp.server; import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.pending.MultipartKey; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; +import gr.cytech.sendium.core.storage.pending.PendingMultipartRecovery; import gr.cytech.sendium.util.MessageUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -17,6 +25,7 @@ import java.util.concurrent.Future; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; /** * This class implements a handler that collects message parts for various concatenated @@ -31,8 +40,9 @@ public class MessagePartsHandler { private final Map> pendingMessageParts; private final Map> scheduledTasks; private final MessagePartsEventsListener listener; + private PendingMultipartRecovery recovery; - private long timeoutInMilliseconds; + private volatile long timeoutInMilliseconds; private ScheduledThreadPoolExecutor executor; public MessagePartsHandler(MessagePartsEventsListener listener, long timeoutInMilliseconds, ScheduledThreadPoolExecutor executor) { @@ -56,6 +66,9 @@ public MessagePartsHandler(MessagePartsEventsListener listener, long timeoutI public synchronized boolean stop() { logger.info("Stopping MessagePartsHandler: {} ...", listener.getName()); + if (recovery != null) { + recovery.close(); + } //process any scheduled tasks and stop the executor executor.shutdown(); @@ -73,6 +86,54 @@ public void setTimeoutInMilliseconds(long timeoutInMilliseconds) { this.timeoutInMilliseconds = timeoutInMilliseconds; } + public long getTimeoutInMilliseconds() { + return timeoutInMilliseconds; + } + + /** Rebuild from a quiescent pending store; the caller supplies its existing coordinated publication boundary. */ + public synchronized void reconstruct(PendingMessageStore pending, int pageSize, BiConsumer, M> publisher) { + if (!pendingMessageParts.isEmpty()) { + throw new IllegalStateException("Reconstruction must precede runtime part handling"); + } + if (recovery != null) { + recovery.close(); + } + recovery = new PendingMultipartRecovery<>(pending, Clock.systemUTC(), executor, publisher); + recovery.reconstruct(pageSize); + } + + public synchronized boolean isRecordDriven() { + return recovery != null; + } + + public synchronized boolean isRecoveryReady() { + return recovery != null && recovery.isReady(); + } + + public synchronized void addAcceptedSource(SourceId source) { + if (recovery == null) { + throw new IllegalStateException("Record-driven reconstruction has not been selected"); + } + recovery.accepted(source); + } + + /** Uses the admission-time group instance and deadline instead of reassigning by a legacy reference. */ + public synchronized void addAcceptedPart(M part, MultipartMembership membership) { + if (!isSupportedMessagePart(part) || !membership.source().value().toString().equalsIgnoreCase(part.serial) || + !membership.key().equals(MultipartKey.from(part)) || membership.ordinal() != MessageUtil.getNumberOfCurrentPart(part)) { + throw new IllegalArgumentException("Part does not match its accepted membership"); + } + if (recovery != null) { + recovery.accepted(membership.source()); + return; + } + if (membership.duplicate()) { + return; + } + long remaining = Math.max(0, Duration.between(Instant.now(), membership.deadline()).toMillis()); + addMessagePart(part, membership.groupId().toString(), remaining); + } + /** * Adds the part in the list of the received parts of the provided message. The message reference * is extracted based on the UDH information in the binheader field. In case the current part @@ -88,7 +149,10 @@ public synchronized void addMessagePart(M part) throws IllegalArgumentException throw new IllegalArgumentException("The provided message part does not contain a supported UDH"); } String msgRefNum = MessageUtil.getMessageReference(part); + addMessagePart(part, msgRefNum, timeoutInMilliseconds); + } + private void addMessagePart(M part, String msgRefNum, long delay) { //Create an empty message parts container if such not exists SortedSet receivedParts = pendingMessageParts.computeIfAbsent(msgRefNum, k -> new ConcurrentSkipListSet<>(new MessagePartsComparator())); @@ -98,7 +162,7 @@ public synchronized void addMessagePart(M part) throws IllegalArgumentException listener.onDuplicateMessagePart(receivedParts.tailSet(part).first(), part); } - scheduledTasks.computeIfAbsent(msgRefNum, this::scheduleDelayedMessagePartsTask); + scheduledTasks.computeIfAbsent(msgRefNum, key -> scheduleDelayedMessagePartsTask(key, delay)); //check if we have received all the message parts if (receivedParts.size() == MessageUtil.getNumberOfTotalParts(part)) { @@ -111,10 +175,10 @@ public synchronized void addMessagePart(M part) throws IllegalArgumentException } } - private Future scheduleDelayedMessagePartsTask(String msgRefNum) { + private Future scheduleDelayedMessagePartsTask(String msgRefNum, long delay) { //schedule a task to notify the listener when a timeout occurs Callable delayedCall = new DelayedMessagePartsTask(msgRefNum); - Future futureCall = executor.schedule(delayedCall, timeoutInMilliseconds, TimeUnit.MILLISECONDS); + Future futureCall = executor.schedule(delayedCall, delay, TimeUnit.MILLISECONDS); return futureCall; } diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/SmppServerWorker.java b/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/SmppServerWorker.java index 7d10c067..27deddb1 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/SmppServerWorker.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/SmppServerWorker.java @@ -30,6 +30,9 @@ import gr.cytech.sendium.core.smpp.util.SmppServerUtil; import gr.cytech.sendium.core.storage.OutboundStage; import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; +import gr.cytech.sendium.core.storage.pending.PendingMultipartRecovery; import gr.cytech.sendium.core.worker.FailDelayPolicy; import gr.cytech.sendium.core.worker.FailDelayPolicyAction; import gr.cytech.sendium.core.worker.WorkerType; @@ -47,6 +50,7 @@ import java.io.IOException; import java.sql.Timestamp; +import java.time.Duration; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.ArrayList; @@ -148,7 +152,7 @@ public class SmppServerWorker extends AbstractOutWork protected MessagePartsHandler messagePartsHandler; protected boolean isFastUnsafeStop = false; private final ReentrantReadWriteLock ingressLifecycleLock = new ReentrantReadWriteLock(true); - private final Map> pendingPartSources = Collections.synchronizedMap(new IdentityHashMap<>()); + private final Map pendingPartSources = Collections.synchronizedMap(new IdentityHashMap<>()); private volatile OutboundCoordinator ingressCoordinator; private volatile boolean ingressStarted; @@ -221,6 +225,19 @@ public void setIngressCoordinator(OutboundCoordinator coordinator) { } } + /** Reconstruct accepted work before startup; this neither opens a backend nor enables a durable profile. */ + public void reconstructPendingParts(PendingMessageStore pending, int pageSize) { + ingressLifecycleLock.writeLock().lock(); + try { + if (ingressStarted || inExecutor != null || ingressCoordinator == null) { + throw new IllegalStateException("Pending reconstruction requires a selected coordinator before startup/admission"); + } + messagePartsHandler.reconstruct(pending, pageSize, ingressCoordinator::makeHeldReady); + } finally { + ingressLifecycleLock.writeLock().unlock(); + } + } + @Override public String getType() { return TYPE_SMPP_SERVER; @@ -230,6 +247,7 @@ public String getType() { public Thread start() { ingressLifecycleLock.writeLock().lock(); try { + requirePendingRecoveryReady(); ingressStarted = true; } finally { ingressLifecycleLock.writeLock().unlock(); @@ -625,6 +643,10 @@ protected boolean configFlagReverseDlrSrcDst() { protected void configReassemblingTimeout() { long timeout = configurationProvider.getLongPrpt(_timeoutMillis); + if (messagePartsHandler != null && messagePartsHandler.isRecordDriven()) { + messagePartsHandler.setTimeoutInMilliseconds(timeout > 0 ? timeout : 30000L); + return; + } if (timeout > 0) { setMessagePartsHandler(new MessagePartsHandler(new CcatMessagePartsEventsListener(), configurationProvider.getLongPrpt(_timeoutMillis))); logger.warn("configReassemblingTimeout|warn|set:{}", timeout); @@ -1018,22 +1040,29 @@ private void acceptIngress(InEvent ine) { private boolean admitToLifecycle(InEvent event, UUID gatewayMessageId) { try { + requirePendingRecoveryReady(); SourceId source = new SourceId(gatewayMessageId); if (event.submitSm != null && !Strings.isNullOrEmpty(event.pMsg.binheader)) { if (!messagePartsHandler.isSupportedMessagePart(event.pMsg)) { throw new OutboundStorageException(OutboundStage.Role.PENDING, OutboundStorageException.Reason.UNSUPPORTED, "Unsupported multipart header"); } - ingressCoordinator.acceptHeld(source, event.pMsg); - synchronized (pendingPartSources) { - pendingPartSources.computeIfAbsent(event.pMsg, ignored -> new HashSet<>()).add(source); + var membership = ingressCoordinator.acceptPart(source, event.pMsg, + Duration.ofMillis(messagePartsHandler.getTimeoutInMilliseconds())); + SourceId acceptedSource = membership.source(); + event.pMsg.serial = acceptedSource.value().toString(); + if (!messagePartsHandler.isRecordDriven()) { + synchronized (pendingPartSources) { + pendingPartSources.put(event.pMsg, membership); + } } InEvent accepted = new InEvent<>(event.pMsg, event.submitSm, event.mpid, event.localTimestamp, - false, event.responseMessageId, Set.of(source)); + false, event.responseMessageId, Set.of(acceptedSource)); accepted.notifyClient = event.notifyClient; inEventQueue.add(accepted); } else { - ingressCoordinator.accept(source, event.pMsg); + SourceId acceptedSource = ingressCoordinator.accept(source, event.pMsg); + event.pMsg.serial = acceptedSource.value().toString(); } if (MessageTrace.shouldLog(configurationProvider, MessageTrace.EVENT_ACCEPTED)) { logger.info("message.accepted ingress=smppserver worker={} {}", getFullName(), MessageTrace.identifiers(event.pMsg)); @@ -1062,6 +1091,13 @@ private void rejectIngress(InEvent event, int status) { } } + private void requirePendingRecoveryReady() { + if (messagePartsHandler != null && messagePartsHandler.isRecordDriven() && !messagePartsHandler.isRecoveryReady()) { + throw new OutboundStorageException(OutboundStage.Role.PENDING, OutboundStorageException.Reason.UNAVAILABLE, + "Pending multipart reconstruction is not ready"); + } + } + protected boolean checkReassembling(M msg) { if (!this.keepOnRunning) { return true; @@ -1091,8 +1127,16 @@ public void handleIngressMessages(List> events) { } if (event.submitSm == null) { ingressCoordinator.makeHeldReady(event.sourceIds, event.pMsg); - } else if (pendingPartSources.containsKey(event.pMsg)) { - messagePartsHandler.addMessagePart(event.pMsg); + } else if (messagePartsHandler.isRecordDriven()) { + event.sourceIds.forEach(messagePartsHandler::addAcceptedSource); + } else { + var membership = pendingPartSources.get(event.pMsg); + if (membership != null) { + messagePartsHandler.addAcceptedPart(event.pMsg, membership); + if (membership.duplicate()) { + pendingPartSources.remove(event.pMsg); + } + } } // A replayed raw event may already have handed its sources to a retained part or ready event. continue; @@ -1409,19 +1453,6 @@ private int getMessageType(StandardMessage msg) throws IOException { } public class CcatMessagePartsEventsListener implements MessagePartsEventsListener { - @Override - public void onDuplicateMessagePart(M original, M duplicate) { - if (ingressCoordinator == null || original == duplicate) { - return; - } - synchronized (pendingPartSources) { - Set originalSources = requirePartSources(original); - Set duplicateSources = requirePartSources(duplicate); - originalSources.addAll(duplicateSources); - pendingPartSources.remove(duplicate); - } - } - @Override public void onMessagePartsHandlingEvent(MessagePartsHandler.MessagePartsEventType type, List parts) { @@ -1435,11 +1466,7 @@ public void onMessagePartsHandlingEvent(MessagePartsHandler.MessagePartsEventTyp } if (type == MessagePartsHandler.MessagePartsEventType.COMPLETE) { //The first part contains the id that we need to send to the provider - M message = parts.getFirst(); - //we make sure that the filter will not process again the reassembled message - message.binheader = null; - message.body = parts.stream().map(m -> m.body).collect(Collectors.joining("")); - message.reassembledParts = parts.stream().map(m -> m.serial).collect(Collectors.toCollection(ArrayList::new)); + M message = PendingMultipartRecovery.assemble(parts); Set allSources = sources.stream().flatMap(Set::stream).collect(Collectors.toSet()); InEvent event = new InEvent<>(message, null, message.onetwork, new Timestamp(message.ctstamp), @@ -1462,11 +1489,11 @@ public void onMessagePartsHandlingEvent(MessagePartsHandler.MessagePartsEventTyp } private Set requirePartSources(M part) { - Set sources = pendingPartSources.get(part); - if (sources == null || sources.isEmpty()) { + var membership = pendingPartSources.get(part); + if (membership == null) { throw new IllegalStateException("Reassembled part has no lifecycle source ownership"); } - return sources; + return Set.of(membership.source()); } @Override diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/PendingMessageStore.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/PendingMessageStore.java index dd15e829..d948f306 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/PendingMessageStore.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/PendingMessageStore.java @@ -2,8 +2,13 @@ import gr.cytech.sendium.core.message.StandardMessage; import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; +import java.time.Duration; +import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.UUID; public interface PendingMessageStore extends OutboundStage { /** @@ -13,11 +18,46 @@ public interface PendingMessageStore extends Outbound * not be acknowledged. This admits ready work, subject to the selection eligibility policy. * Repeating either admission method preserves the first accepted payload and held/ready disposition. * Deduplication after terminal source removal is not promised. + * Returns the supplied stable source handle only after backend acceptance; never returns null + * or substitutes another source ID. Callers must use this result before acknowledging success. */ - void admit(SourceId source, M message); + SourceId admit(SourceId source, M message); /** Retains an accepted source without making it eligible for selection until makeHeldReady succeeds. */ - void admitHeld(SourceId source, M message); + SourceId admitHeld(SourceId source, M message); + + /** + * Atomically accepts a held part with its immutable membership before acknowledgement. Join the open, + * unexpired matching group or create a new group. The first accepted source for an ordinal wins; + * later sources report duplicate membership referencing that original source, without replacing its payload. + * All accepted handles remain owned until terminal completion. The first part fixes the deadline using timeout; + * later arrivals never extend it. An internal retry of a retained multipart source returns its original membership. + * A source already admitted without multipart membership cannot be converted through this method. + */ + MultipartMembership admitPart(SourceId source, M message, Duration timeout); + + /** Returns recorded membership for a retained multipart source; ordinary or absent sources have no membership. */ + Optional findMultipart(SourceId source); + + /** + * Returns retained handles assigned to one group, including duplicates, bounded by pending source capacity. + * Includes members awaiting ingress processing, so expiry does not depend on notification order. + * Unknown or fully completed groups return an empty list; backend failures must still propagate. + */ + List multipartSources(UUID groupId); + + /** Returns an isolated accepted payload, or absence established by a ready backend; failures are not absence. */ + Optional find(SourceId source); + + /** + * Returns at most limit retained source handles, including held/selected/routed sources, in UUID.compareTo + * order strictly after afterExclusive. Pass null to start; continue from the last returned ID, even if that + * source has since completed. An empty page ends traversal. limit must be positive. Pages are immutable. + * This is bounded recovery access, not selection or a payload backlog copy. A quiescent scan visits all + * retained sources once. Concurrent admission at/before the cursor may be missed; completed sources may + * disappear before find. Startup recovery must quiesce admission/processing rather than assume a snapshot. + */ + List sourcesAfter(SourceId afterExclusive, int limit); /** * Makes held sources ready using one execution message referring to their nonempty source set. Original @@ -26,12 +66,19 @@ public interface PendingMessageStore extends Outbound * While all sources remain retained, repeating the exact source set preserves the first published * payload and cannot duplicate work, including after selection/routing. Missing, ordinarily admitted, * or differently grouped sources fail explicitly. The caller owns assembly/expiry policy. + * Assigned multipart ordinals may be published together only as their complete recorded group; + * partial/expired ordinals are published individually, never mixed with another group or unassigned held sources. + * Publication of original sources atomically includes every retained duplicate of those ordinals, even if + * its ingress notification has not been processed. A duplicate cannot be published without its original. */ void makeHeldReady(Set sources, M message); /** - * Called only after all work and required handoffs are terminal. Missing sources are already - * complete. Partial multi-source completion is allowed; retrying the full set must be safe. + * Called only after all work and required handoffs are terminal. Successful return confirms that + * every requested source is absent. Missing sources count as complete only when a ready backend can + * establish their absence; unavailability, corrupt state, or inaccessible ownership must fail explicitly. + * A failed call may have removed some or all requested sources before its result became uncertain. + * Retrying the full source set must therefore be safe; a failure must never be treated as established absence. */ void complete(Set sources); } diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/memory/MemoryPendingMessageStore.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/memory/MemoryPendingMessageStore.java index f9e2abfb..4baa8d39 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/memory/MemoryPendingMessageStore.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/memory/MemoryPendingMessageStore.java @@ -4,10 +4,18 @@ import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; import gr.cytech.sendium.core.storage.OutboundStorageException; import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.pending.MultipartKey; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; +import gr.cytech.sendium.util.MessageUtil; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; +import java.util.BitSet; import java.util.Comparator; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.NavigableMap; import java.util.NavigableSet; @@ -16,6 +24,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.UUID; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; @@ -39,11 +48,15 @@ public final class MemoryPendingMessageStore implemen private final int capacity; private final UnaryOperator snapshots; private final Function eligibleAt; - private final Map> records = new HashMap<>(); + private final Clock clock; + private final NavigableMap> records = new TreeMap<>(Comparator.comparing(SourceId::value)); + private final Map> openGroups = new HashMap<>(); + private final Map retainedGroups = new HashMap<>(); private final NavigableMap>> waiting = new TreeMap<>(Comparator.reverseOrder()); private State state = State.NEW; private Object selectorOwner; private long sequence; + private long multipartSequence; public MemoryPendingMessageStore(int capacity, UnaryOperator snapshots) { this(capacity, snapshots, message -> Instant.MIN); @@ -51,12 +64,17 @@ public MemoryPendingMessageStore(int capacity, UnaryOperator snapshots) { /** Eligibility is evaluated when ready work is first published; held admission does not evaluate it. */ public MemoryPendingMessageStore(int capacity, UnaryOperator snapshots, Function eligibleAt) { + this(capacity, snapshots, eligibleAt, Clock.systemUTC()); + } + + public MemoryPendingMessageStore(int capacity, UnaryOperator snapshots, Function eligibleAt, Clock clock) { if (capacity <= 0) { throw new IllegalArgumentException("Pending capacity must be positive"); } this.capacity = capacity; this.snapshots = Objects.requireNonNull(snapshots, "snapshots"); this.eligibleAt = Objects.requireNonNull(eligibleAt, "eligibleAt"); + this.clock = Objects.requireNonNull(clock, "clock"); } @Override @@ -83,18 +101,18 @@ public Status status() { } @Override - public void admit(SourceId source, M message) { - admit(source, message, false); + public SourceId admit(SourceId source, M message) { + return admit(source, message, false); } - private void admit(SourceId source, M message, boolean held) { + private SourceId admit(SourceId source, M message, boolean held) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(message, "message"); lock.lock(); try { requireReady(Role.PENDING); if (records.containsKey(source)) { - return; + return source; } if (records.size() >= capacity) { throw new OutboundStorageException(Role.PENDING, CAPACITY_EXCEEDED, "Memory pending capacity reached"); @@ -107,19 +125,121 @@ private void admit(SourceId source, M message, boolean held) { accepted.ready = ready; addWaiting(ready); } + return source; } finally { lock.unlock(); } } @Override - public void admitHeld(SourceId source, M message) { - admit(source, message, true); + public SourceId admitHeld(SourceId source, M message) { + return admit(source, message, true); + } + + @Override + public MultipartMembership admitPart(SourceId source, M message, Duration timeout) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(message, "message"); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("Multipart timeout must be positive"); + } + lock.lock(); + try { + requireReady(Role.PENDING); + Source existing = records.get(source); + if (existing != null) { + if (existing.membership == null) { + throw new OutboundStorageException(Role.PENDING, INVALID_TRANSITION, "Source has no multipart membership"); + } + return existing.membership; + } + if (records.size() >= capacity) { + throw new OutboundStorageException(Role.PENDING, CAPACITY_EXCEEDED, "Memory pending capacity reached"); + } + M snapshot = snapshot(message, Role.PENDING); + MultipartKey key; + int ordinal; + try { + if (!source.value().toString().equalsIgnoreCase(snapshot.serial)) { + throw new IllegalArgumentException("Multipart source identity mismatch"); + } + key = MultipartKey.from(snapshot); + ordinal = MessageUtil.concatenation(snapshot).orElseThrow().ordinal(); + } catch (IllegalArgumentException failure) { + throw new OutboundStorageException(Role.PENDING, UNSUPPORTED, "Unsupported multipart identity"); + } + Instant now = clock.instant(); + var groups = openGroups.getOrDefault(key, new ArrayList<>()); + groups.removeIf(group -> !group.deadline.isAfter(now)); + MultipartGroup chosen = null; + for (MultipartGroup group : groups) { + chosen = group; + break; + } + if (chosen == null) { + chosen = new MultipartGroup(key, now.plus(timeout), multipartSequence++); + groups.add(chosen); + } + SourceId original = chosen.sources.getOrDefault(ordinal, source); + var membership = new MultipartMembership(source, chosen.id, key, ordinal, chosen.deadline, chosen.order, original); + records.put(source, new Source<>(snapshot, membership, chosen)); + chosen.sources.putIfAbsent(ordinal, source); + chosen.allSources.add(source); + retainedGroups.put(chosen.id, chosen); + openGroups.put(key, groups); + chosen.ordinals.set(ordinal); + if (chosen.ordinals.cardinality() == key.total()) { + closeGroup(chosen); + } + return membership; + } finally { + lock.unlock(); + } + } + + @Override + public Optional findMultipart(SourceId source) { + Objects.requireNonNull(source, "source"); + lock.lock(); + try { + requireReady(Role.PENDING); + Source accepted = records.get(source); + return accepted == null ? Optional.empty() : Optional.ofNullable(accepted.membership); + } finally { + lock.unlock(); + } + } + + private void closeGroup(MultipartGroup group) { + if (group == null) { + return; + } + List groups = openGroups.get(group.key); + if (groups != null) { + groups.remove(group); + if (groups.isEmpty()) { + openGroups.remove(group.key); + } + } + } + + @Override + public List multipartSources(UUID groupId) { + Objects.requireNonNull(groupId, "groupId"); + lock.lock(); + try { + requireReady(Role.PENDING); + MultipartGroup group = retainedGroups.get(groupId); + return group == null ? List.of() : List.copyOf(group.allSources); + } finally { + lock.unlock(); + } } @Override public void makeHeldReady(Set sources, M message) { - Set checked = Set.copyOf(sources); + Set checked = new java.util.HashSet<>(sources); Objects.requireNonNull(message, "message"); if (checked.isEmpty()) { throw new IllegalArgumentException("Ready work must reference at least one source"); @@ -128,11 +248,18 @@ public void makeHeldReady(Set sources, M message) { try { requireReady(Role.PENDING); Entry existing = null; + MultipartGroup assignedGroup = null; for (SourceId source : checked) { Source accepted = records.get(source); if (accepted == null || !accepted.initiallyHeld) { throw invalidPublication(); } + if (accepted.group != null) { + if (assignedGroup != null && assignedGroup != accepted.group) { + throw invalidPublication(); + } + assignedGroup = accepted.group; + } if (accepted.ready != null) { if (existing != null && accepted.ready != existing) { throw invalidPublication(); @@ -140,6 +267,24 @@ public void makeHeldReady(Set sources, M message) { existing = accepted.ready; } } + if (assignedGroup != null) { + for (SourceId source : checked) { + if (records.get(source).group != assignedGroup || !checked.contains(records.get(source).membership.originalSource())) { + throw invalidPublication(); + } + } + Set originals = checked.stream().map(source -> records.get(source).membership.originalSource()) + .collect(java.util.stream.Collectors.toSet()); + if (originals.size() > 1 && originals.size() != assignedGroup.key.total()) { + throw invalidPublication(); + } + for (SourceId source : assignedGroup.allSources) { + if (originals.contains(records.get(source).membership.originalSource())) { + checked.add(source); + } + } + } + checked = Set.copyOf(checked); if (existing != null) { if (!existing.sources.equals(checked)) { throw invalidPublication(); @@ -154,6 +299,7 @@ public void makeHeldReady(Set sources, M message) { Entry entry = readyEntry(checked, snapshot(message, Role.PENDING)); for (SourceId source : checked) { records.get(source).ready = entry; + closeGroup(records.get(source).group); } addWaiting(entry); } finally { @@ -182,7 +328,7 @@ private static OutboundStorageException invalidPublication() { "Ready publication requires retained held sources with one matching source set"); } - /** Single-source lookup for memory-backend consumers; returns an isolated execution value. */ + @Override public Optional find(SourceId source) { Objects.requireNonNull(source, "source"); lock.lock(); @@ -195,6 +341,28 @@ public Optional find(SourceId source) { } } + @Override + public List sourcesAfter(SourceId afterExclusive, int limit) { + if (limit <= 0) { + throw new IllegalArgumentException("Recovery page limit must be positive"); + } + lock.lock(); + try { + requireReady(Role.PENDING); + var remaining = afterExclusive == null ? records : records.tailMap(afterExclusive, false); + var page = new ArrayList(); + for (SourceId source : remaining.navigableKeySet()) { + if (page.size() == limit) { + break; + } + page.add(source); + } + return List.copyOf(page); + } finally { + lock.unlock(); + } + } + @Override public void complete(Set sources) { Set checked = Set.copyOf(sources); @@ -203,8 +371,17 @@ public void complete(Set sources) { requireReady(Role.PENDING); for (SourceId source : checked) { Source entry = records.remove(source); - if (entry != null && entry.ready != null) { - removeWaiting(entry.ready); + if (entry != null) { + closeGroup(entry.group); + if (entry.group != null) { + entry.group.allSources.remove(source); + if (entry.group.allSources.isEmpty()) { + retainedGroups.remove(entry.group.id); + } + } + if (entry.ready != null) { + removeWaiting(entry.ready); + } } } } finally { @@ -218,6 +395,8 @@ public void close() { try { state = State.CLOSED; records.clear(); + openGroups.clear(); + retainedGroups.clear(); waiting.clear(); changed.signalAll(); } finally { @@ -297,11 +476,38 @@ static final class Entry { private static final class Source { private final M message; private final boolean initiallyHeld; + private final MultipartMembership membership; + private final MultipartGroup group; private Entry ready; private Source(M message, boolean held) { this.message = message; this.initiallyHeld = held; + this.membership = null; + this.group = null; + } + + private Source(M message, MultipartMembership membership, MultipartGroup group) { + this.message = message; + this.initiallyHeld = true; + this.membership = membership; + this.group = group; + } + } + + private static final class MultipartGroup { + private final UUID id = UUID.randomUUID(); + private final MultipartKey key; + private final Instant deadline; + private final long order; + private final BitSet ordinals = new BitSet(); + private final NavigableMap sources = new TreeMap<>(); + private final Set allSources = new java.util.LinkedHashSet<>(); + + private MultipartGroup(MultipartKey key, Instant deadline, long order) { + this.key = key; + this.deadline = deadline; + this.order = order; } } } diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/MultipartKey.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/MultipartKey.java new file mode 100644 index 00000000..e0a9d29c --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/MultipartKey.java @@ -0,0 +1,24 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.util.MessageUtil; + +import java.util.Objects; + +/** Logical matching identity; several independent group instances may share this key. */ +public record MultipartKey(String accountId, String systemId, String sender, String recipient, + int referenceBits, int reference, int total, String normalizedUdh) { + public MultipartKey { + Objects.requireNonNull(normalizedUdh, "normalizedUdh"); + if ((referenceBits != 8 && referenceBits != 16) || reference < 0 || + reference > (referenceBits == 8 ? 255 : 65535) || total < 1 || total > 255) { + throw new IllegalArgumentException("Invalid multipart identity"); + } + } + + public static MultipartKey from(StandardMessage message) { + var part = MessageUtil.concatenation(message).orElseThrow(() -> new IllegalArgumentException("Unsupported multipart header")); + return new MultipartKey(message.owner_id, message.systemId, message.from, message.to, + part.referenceBits(), part.reference(), part.total(), part.normalizedUdh()); + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/MultipartMembership.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/MultipartMembership.java new file mode 100644 index 00000000..2ea4ab8d --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/MultipartMembership.java @@ -0,0 +1,30 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Immutable admission facts: original source UUID, assigned group instance, ordinal and saved deadline. */ +public record MultipartMembership(SourceId source, UUID groupId, MultipartKey key, int ordinal, Instant deadline, long groupOrder, + SourceId originalSource) { + public MultipartMembership(SourceId source, UUID groupId, MultipartKey key, int ordinal, Instant deadline, long groupOrder) { + this(source, groupId, key, ordinal, deadline, groupOrder, source); + } + + public MultipartMembership { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(originalSource, "originalSource"); + Objects.requireNonNull(groupId, "groupId"); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(deadline, "deadline"); + if (ordinal < 1 || ordinal > key.total() || groupOrder < 0) { + throw new IllegalArgumentException("Invalid multipart membership"); + } + } + + public boolean duplicate() { + return !source.equals(originalSource); + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingExtensionValues.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingExtensionValues.java new file mode 100644 index 00000000..b0bb7c5d --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingExtensionValues.java @@ -0,0 +1,164 @@ +package gr.cytech.sendium.core.storage.pending; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** A closed value vocabulary; type tags are schema names, never Java class names. */ +final class PendingExtensionValues { + private static final JsonNodeFactory NODES = JsonNodeFactory.instance; + private static final int MAX_DEPTH = 64; + + private PendingExtensionValues() { + } + + static JsonNode encode(Object value) { + return encode(value, new IdentityHashMap<>(), 0); + } + + private static JsonNode encode(Object value, IdentityHashMap path, int depth) { + if (depth > MAX_DEPTH) { + throw PendingRecordFields.unsupported(); + } + if (value == null) { + return NODES.nullNode(); + } + ObjectNode result = NODES.objectNode(); + switch (value) { + case String text -> scalar(result, "string", NODES.textNode(text)); + case Boolean flag -> scalar(result, "boolean", NODES.booleanNode(flag)); + case Byte number -> scalar(result, "int8", NODES.numberNode(number)); + case Short number -> scalar(result, "int16", NODES.numberNode(number)); + case Integer number -> scalar(result, "int32", NODES.numberNode(number)); + case Long number -> scalar(result, "int64", NODES.numberNode(number)); + case Float number -> { + if (!Float.isFinite(number)) { + throw PendingRecordFields.unsupported(); + } + scalar(result, "float32", NODES.numberNode(number)); + } + case Double number -> { + if (!Double.isFinite(number)) { + throw PendingRecordFields.unsupported(); + } + scalar(result, "float64", NODES.numberNode(number)); + } + case byte[] bytes -> scalar(result, "bytes", NODES.binaryNode(bytes.clone())); + case List list -> { + enter(path, value); + try { + var items = NODES.arrayNode(); + for (Object item : list) { + items.add(encode(item, path, depth + 1)); + } + scalar(result, "list", items); + } finally { + path.remove(value); + } + } + case Map map -> { + enter(path, value); + try { + var sorted = new TreeMap(); + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw PendingRecordFields.unsupported(); + } + sorted.put(key, entry.getValue()); + } + var entries = NODES.objectNode(); + sorted.forEach((key, item) -> entries.set(key, encode(item, path, depth + 1))); + scalar(result, "map", entries); + } finally { + path.remove(value); + } + } + default -> throw PendingRecordFields.unsupported(); + } + return result; + } + + private static void scalar(ObjectNode result, String type, JsonNode value) { + result.put("type", type); + result.set("value", value); + } + + private static void enter(IdentityHashMap path, Object value) { + if (path.put(value, Boolean.TRUE) != null) { + throw PendingRecordFields.unsupported(); + } + } + + static Object decode(JsonNode node) { + return decode(node, 0); + } + + private static Object decode(JsonNode node, int depth) { + if (depth > MAX_DEPTH) { + throw PendingRecordFields.corrupt(); + } + if (node.isNull()) { + return null; + } + if (!node.isObject()) { + throw PendingRecordFields.corrupt(); + } + String type = PendingRecordFields.nonNullText(PendingRecordFields.required(node, "type")); + JsonNode value = PendingRecordFields.required(node, "value"); + return switch (type) { + case "string" -> PendingRecordFields.nonNullText(value); + case "boolean" -> PendingRecordFields.bool(value); + case "int8" -> (byte) PendingRecordFields.integer(value, Byte.MIN_VALUE, Byte.MAX_VALUE); + case "int16" -> (short) PendingRecordFields.integer(value, Short.MIN_VALUE, Short.MAX_VALUE); + case "int32" -> PendingRecordFields.integer(value, Integer.MIN_VALUE, Integer.MAX_VALUE); + case "int64" -> PendingRecordFields.integer(value); + case "float32" -> { + double number = PendingRecordFields.decimal(value); + float narrowed = (float) number; + if (!Float.isFinite(narrowed) || (double) narrowed != number) { + throw PendingRecordFields.corrupt(); + } + yield narrowed; + } + case "float64" -> PendingRecordFields.decimal(value); + case "bytes" -> bytes(value); + case "list" -> { + if (!value.isArray()) { + throw PendingRecordFields.corrupt(); + } + var items = new ArrayList<>(); + for (JsonNode item : value) { + items.add(decode(item, depth + 1)); + } + yield items; + } + case "map" -> { + if (!value.isObject()) { + throw PendingRecordFields.corrupt(); + } + var entries = new TreeMap(); + value.properties().forEach(entry -> entries.put(entry.getKey(), decode(entry.getValue(), depth + 1))); + yield entries; + } + default -> throw PendingRecordFields.corrupt(); + }; + } + + private static byte[] bytes(JsonNode node) { + if (!node.isBinary()) { + throw PendingRecordFields.corrupt(); + } + try { + return node.binaryValue().clone(); + } catch (IOException failure) { + throw PendingRecordFields.corrupt(); + } + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMessageData.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMessageData.java new file mode 100644 index 00000000..e68621fa --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMessageData.java @@ -0,0 +1,281 @@ +package gr.cytech.sendium.core.storage.pending; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gr.cytech.sendium.core.message.DlrReturnMetadata; +import gr.cytech.sendium.core.message.StandardMessage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +/** Detached accepted-message data with an explicit field mapping, not reflective message serialization. */ +public final class PendingMessageData { + private static final JsonNodeFactory NODES = JsonNodeFactory.instance; + private final ObjectNode fields; + + private PendingMessageData(ObjectNode fields) { + this.fields = fields; + } + + public static PendingMessageData capture(StandardMessage message) { + if (message == null || message.getClass() != StandardMessage.class || !Double.isFinite(message.marginPercentage)) { + throw PendingRecordFields.unsupported(); + } + ObjectNode data = NODES.objectNode(); + data.put("from", message.from); + data.put("to", message.to); + data.put("timestamp", message.timestamp); + data.put("type", message.type); + data.put("body", message.body); + data.put("serial", message.serial); + data.put("messageCenter", message.message_center); + data.put("ownerId", message.owner_id); + data.put("systemId", message.systemId); + data.put("priority", message.priority); + data.put("acked", message.acked); + data.put("msgId", message.msgId); + data.put("expr", message.expr); + data.put("rtxCnt", message.rtxCnt); + data.put("binbody", message.binbody); + data.put("binheader", message.binheader); + data.put("flash", message.flash); + data.put("mclass", message.mclass); + data.put("ttl", message.ttl); + data.put("ddt", message.ddt); + data.put("dcs", message.dcs); + data.put("onetwork", message.onetwork); + data.put("cnetwork", message.cnetwork); + data.put("ingateway", message.ingateway); + data.put("outgateway", message.outgateway); + data.put("state", message.state); + data.put("cost", message.cost); + data.put("ctstamp", message.ctstamp); + data.put("sutstamp", message.sutstamp); + data.put("errcode", message.errcode); + data.put("extrid", message.extrid); + data.put("nextTarget", message.nextTarget); + data.set("field1", PendingExtensionValues.encode(message.field1)); + data.set("field2", PendingExtensionValues.encode(message.field2)); + data.set("field3", PendingExtensionValues.encode(message.field3)); + data.set("field4", PendingExtensionValues.encode(message.field4)); + data.set("field5", PendingExtensionValues.encode(message.field5)); + data.set("field6", PendingExtensionValues.encode(message.field6)); + data.set("field7", PendingExtensionValues.encode(message.field7)); + data.set("field8", PendingExtensionValues.encode(message.field8)); + data.set("field9", PendingExtensionValues.encode(message.field9)); + data.set("field10", PendingExtensionValues.encode(message.field10)); + data.put("paid", message.paid); + data.put("metadata", message.metadata); + data.put("smsCnt", message.smsCnt); + data.put("smsSubmitCnt", message.smsSubmitCnt); + data.set("attrs", PendingExtensionValues.encode(message.attrs)); + data.set("reassembledParts", strings(message.reassembledParts)); + data.set("dlrReturnMetadata", dlr(message.dlrReturnMetadata)); + data.set("tlvs", tlvs(message.tlvs)); + data.put("dispatchType", message.dispatchType); + data.put("hlrRoute", message.hlrRoute); + data.put("marginPercentage", message.marginPercentage); + data.put("marginStatus", message.marginStatus == null ? null : message.marginStatus.name()); + return new PendingMessageData(data); + } + + public StandardMessage restore() { + StandardMessage message = new StandardMessage(); + message.from = text("from"); + message.to = text("to"); + message.timestamp = text("timestamp"); + message.type = integer("type"); + message.body = text("body"); + message.serial = text("serial"); + message.message_center = text("messageCenter"); + message.owner_id = text("ownerId"); + message.systemId = text("systemId"); + message.priority = integer("priority"); + message.acked = bool("acked"); + message.msgId = integer("msgId"); + message.expr = longValue("expr"); + message.rtxCnt = integer("rtxCnt"); + message.binbody = text("binbody"); + message.binheader = text("binheader"); + message.flash = bool("flash"); + message.mclass = integer("mclass"); + message.ttl = integer("ttl"); + message.ddt = integer("ddt"); + message.dcs = (byte) PendingRecordFields.integer(field("dcs"), Byte.MIN_VALUE, Byte.MAX_VALUE); + message.onetwork = integer("onetwork"); + message.cnetwork = integer("cnetwork"); + message.ingateway = text("ingateway"); + message.outgateway = text("outgateway"); + message.state = integer("state"); + message.cost = longValue("cost"); + message.ctstamp = longValue("ctstamp"); + message.sutstamp = longValue("sutstamp"); + message.errcode = text("errcode"); + message.extrid = text("extrid"); + message.nextTarget = text("nextTarget"); + message.field1 = PendingExtensionValues.decode(field("field1")); + message.field2 = PendingExtensionValues.decode(field("field2")); + message.field3 = PendingExtensionValues.decode(field("field3")); + message.field4 = PendingExtensionValues.decode(field("field4")); + message.field5 = PendingExtensionValues.decode(field("field5")); + message.field6 = PendingExtensionValues.decode(field("field6")); + message.field7 = PendingExtensionValues.decode(field("field7")); + message.field8 = PendingExtensionValues.decode(field("field8")); + message.field9 = PendingExtensionValues.decode(field("field9")); + message.field10 = PendingExtensionValues.decode(field("field10")); + message.paid = bool("paid"); + message.metadata = text("metadata"); + message.smsCnt = integer("smsCnt"); + message.smsSubmitCnt = integer("smsSubmitCnt"); + message.attrs = attributes(field("attrs")); + message.reassembledParts = readStrings(field("reassembledParts")); + message.dlrReturnMetadata = readDlr(field("dlrReturnMetadata")); + message.tlvs = readTlvs(field("tlvs")); + message.dispatchType = integer("dispatchType"); + message.hlrRoute = text("hlrRoute"); + message.marginPercentage = PendingRecordFields.decimal(field("marginPercentage")); + String status = text("marginStatus"); + try { + message.marginStatus = status == null ? null : StandardMessage.MarginStatus.valueOf(status); + } catch (IllegalArgumentException failure) { + throw PendingRecordFields.corrupt(); + } + return message; + } + + String gatewayId() { + return text("serial"); + } + + ObjectNode toNode() { + // Restore/capture also copies binary nodes, whose backing arrays deepCopy alone would share. + return capture(restore()).fields; + } + + static PendingMessageData fromNode(JsonNode node) { + if (!node.isObject()) { + throw PendingRecordFields.corrupt(); + } + return capture(new PendingMessageData((ObjectNode) node).restore()); + } + + private JsonNode field(String name) { + return PendingRecordFields.required(fields, name); + } + + private String text(String name) { + return PendingRecordFields.text(field(name)); + } + + private int integer(String name) { + return PendingRecordFields.integer(field(name), Integer.MIN_VALUE, Integer.MAX_VALUE); + } + + private long longValue(String name) { + return PendingRecordFields.integer(field(name)); + } + + private boolean bool(String name) { + return PendingRecordFields.bool(field(name)); + } + + private static JsonNode strings(ArrayList values) { + if (values == null) { + return NODES.nullNode(); + } + var result = NODES.arrayNode(); + values.forEach(result::add); + return result; + } + + private static ArrayList readStrings(JsonNode node) { + if (node.isNull()) { + return null; + } + if (!node.isArray()) { + throw PendingRecordFields.corrupt(); + } + var result = new ArrayList(); + node.forEach(value -> result.add(PendingRecordFields.text(value))); + return result; + } + + private static JsonNode tlvs(HashMap values) { + if (values == null) { + return NODES.nullNode(); + } + var result = NODES.objectNode(); + try { + new TreeMap<>(values).forEach(result::put); + } catch (ClassCastException | NullPointerException failure) { + throw PendingRecordFields.unsupported(); + } + return result; + } + + private static HashMap readTlvs(JsonNode node) { + if (node.isNull()) { + return null; + } + if (!node.isObject()) { + throw PendingRecordFields.corrupt(); + } + var result = new HashMap(); + node.properties().forEach(entry -> result.put(entry.getKey(), PendingRecordFields.text(entry.getValue()))); + return result; + } + + private static Map attributes(JsonNode node) { + Object value = PendingExtensionValues.decode(node); + if (value == null) { + return null; + } + if (!(value instanceof Map map)) { + throw PendingRecordFields.corrupt(); + } + var result = new HashMap(); + map.forEach((key, item) -> result.put((String) key, item)); + return result; + } + + private static JsonNode dlr(DlrReturnMetadata metadata) { + if (metadata == null) { + return NODES.nullNode(); + } + if (metadata.channel() == null) { + throw PendingRecordFields.unsupported(); + } + var result = NODES.objectNode(); + result.put("channel", metadata.channel().name()); + result.put("accountId", metadata.accountId()); + result.put("systemId", metadata.systemId()); + result.put("sourceAddress", metadata.sourceAddress()); + result.put("destinationAddress", metadata.destinationAddress()); + result.put("forwardDlrUrl", metadata.forwardDlrUrl()); + return result; + } + + private static DlrReturnMetadata readDlr(JsonNode node) { + if (node.isNull()) { + return null; + } + if (!node.isObject()) { + throw PendingRecordFields.corrupt(); + } + try { + var channel = DlrReturnMetadata.DeliveryChannel.valueOf( + PendingRecordFields.nonNullText(PendingRecordFields.required(node, "channel"))); + return new DlrReturnMetadata(channel, + PendingRecordFields.text(PendingRecordFields.required(node, "accountId")), + PendingRecordFields.text(PendingRecordFields.required(node, "systemId")), + PendingRecordFields.text(PendingRecordFields.required(node, "sourceAddress")), + PendingRecordFields.text(PendingRecordFields.required(node, "destinationAddress")), + PendingRecordFields.text(PendingRecordFields.required(node, "forwardDlrUrl"))); + } catch (IllegalArgumentException failure) { + throw PendingRecordFields.corrupt(); + } + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecovery.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecovery.java new file mode 100644 index 00000000..78777dd6 --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecovery.java @@ -0,0 +1,279 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +/** Rebuilds runtime assembly from retained source facts; never checkpoints routing/publication decisions. */ +public final class PendingMultipartRecovery implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(PendingMultipartRecovery.class); + private final PendingMessageStore pending; + private final Clock clock; + private final ScheduledExecutorService scheduler; + private final BiConsumer, M> publisher; + private final Map groups = new HashMap<>(); + private volatile boolean ready; + private boolean closed; + + public PendingMultipartRecovery(PendingMessageStore pending, Clock clock, ScheduledExecutorService scheduler, + BiConsumer, M> publisher) { + this.pending = Objects.requireNonNull(pending, "pending"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.publisher = Objects.requireNonNull(publisher, "publisher"); + } + + /** Caller quiesces ingress/processing until successful return; no publication occurs before scan validation. */ + public synchronized void reconstruct(int pageSize) { + if (closed || pageSize <= 0) { + throw new IllegalStateException("Recovery requires an open component and positive page size"); + } + ready = false; + cancelTimers(); + groups.clear(); + try { + SourceId cursor = null; + while (true) { + List page = pending.sourcesAfter(cursor, pageSize); + if (page.size() > pageSize) { + throw inconsistent(); + } + if (page.isEmpty()) { + break; + } + for (SourceId source : page) { + if (source == null || (cursor != null && source.value().compareTo(cursor.value()) <= 0)) { + throw inconsistent(); + } + pending.findMultipart(source).ifPresent(membership -> index(source, membership)); + cursor = source; + } + } + // Only handles/metadata are sorted here, not the backlog's message payloads. + var ordered = new ArrayList<>(groups.values()); + ordered.sort(Comparator.comparingLong(group -> group.identity.groupOrder())); + for (Group group : ordered) { + group.validateOriginals(); + List indexed = pending.multipartSources(group.identity.groupId()); + if (indexed.size() != group.members.size() || !Set.copyOf(indexed).equals(group.members.keySet())) { + throw inconsistent(); + } + } + for (Group group : ordered) { + process(group); + } + ready = true; + } catch (RuntimeException | Error failure) { + cancelTimers(); + throw failure; + } + } + + public boolean isReady() { + return ready; + } + + /** Notification contains only a handle; accepted payload and current group members remain store-owned. */ + public synchronized void accepted(SourceId source) { + if (!ready || closed) { + throw new OutboundStorageException(OutboundStage.Role.PENDING, OutboundStorageException.Reason.UNAVAILABLE, + "Pending multipart reconstruction is not ready"); + } + try { + var membership = pending.findMultipart(Objects.requireNonNull(source, "source")); + if (membership.isPresent()) { + Group group = index(source, membership.orElseThrow()); + process(group); + } + } catch (RuntimeException failure) { + if (invalidState(failure)) { + ready = false; + cancelTimers(); + } + throw failure; + } + } + + private Group index(SourceId source, MultipartMembership membership) { + if (!source.equals(membership.source())) { + throw inconsistent(); + } + Group group = groups.computeIfAbsent(membership.groupId(), ignored -> new Group(membership)); + group.add(membership); + return group; + } + + private void process(Group group) { + // Establish expiry before reading membership: a later admission cannot join an already-expired group. + final Instant now = clock.instant(); + List retained = pending.multipartSources(group.identity.groupId()); + if (Set.copyOf(retained).size() != retained.size()) { + throw inconsistent(); + } + group.sources.clear(); + group.members.clear(); + for (SourceId source : retained) { + var membership = pending.findMultipart(source).orElseThrow(PendingMultipartRecovery::inconsistent); + if (!source.equals(membership.source())) { + throw inconsistent(); + } + group.add(membership); + } + group.validateOriginals(); + if (group.sources.isEmpty()) { + forget(group); + } else if (group.sources.size() == group.identity.key().total()) { + var parts = new ArrayList(); + for (SourceId source : group.sources.values()) { + parts.add(payload(source)); + } + publisher.accept(Set.copyOf(group.members.keySet()), assemble(parts)); + forget(group); + } else if (!group.identity.deadline().isAfter(now)) { + for (SourceId source : group.sources.values()) { + Set owned = group.members.values().stream() + .filter(member -> member.originalSource().equals(source)).map(MultipartMembership::source) + .collect(Collectors.toSet()); + publisher.accept(owned, payload(source)); + } + forget(group); + } else { + long delay = Math.max(1, Duration.between(clock.instant(), group.identity.deadline()).toMillis()); + schedule(group, delay); + } + } + + private M payload(SourceId source) { + return pending.find(source).orElseThrow(PendingMultipartRecovery::inconsistent); + } + + private void schedule(Group group, long delayMillis) { + if (group.timer != null) { + group.timer.cancel(false); + } + group.timer = scheduler.schedule(() -> expire(group), delayMillis, TimeUnit.MILLISECONDS); + } + + private synchronized void expire(Group expected) { + if (closed || !ready || groups.get(expected.identity.groupId()) != expected) { + return; + } + expected.timer = null; + try { + process(expected); + } catch (RuntimeException failure) { + var reason = failure instanceof OutboundStorageException storage ? storage.reason() : OutboundStorageException.Reason.UNAVAILABLE; + LOGGER.warn("Pending multipart processing failed stage=PENDING reason={}", reason); + if (invalidState(failure)) { + ready = false; + cancelTimers(); + } else { + schedule(expected, 1000); + } + } + } + + private void forget(Group group) { + if (group.timer != null) { + group.timer.cancel(false); + } + groups.remove(group.identity.groupId()); + } + + private void cancelTimers() { + for (Group group : groups.values()) { + if (group.timer != null) { + group.timer.cancel(false); + } + } + } + + /** Existing SMPP assembly semantics, operating only on detached runtime payloads in ordinal order. */ + public static M assemble(List parts) { + if (parts.isEmpty()) { + throw new IllegalArgumentException("Assembly requires received parts"); + } + M message = parts.getFirst(); + String body = parts.stream().map(part -> part.body).collect(Collectors.joining("")); + var sourceIds = parts.stream().map(part -> part.serial).collect(Collectors.toCollection(ArrayList::new)); + message.binheader = null; + message.body = body; + message.reassembledParts = sourceIds; + return message; + } + + @Override + public synchronized void close() { + ready = false; + closed = true; + cancelTimers(); + groups.clear(); + // Scheduler and store are application-owned, not this component's resources. + } + + private static OutboundStorageException inconsistent() { + return new OutboundStorageException(OutboundStage.Role.PENDING, OutboundStorageException.Reason.OWNERSHIP_CONFLICT, + "Inconsistent pending multipart recovery state"); + } + + private static boolean invalidState(RuntimeException failure) { + return failure instanceof PendingRecordException || (failure instanceof OutboundStorageException storage && + storage.reason() == OutboundStorageException.Reason.OWNERSHIP_CONFLICT); + } + + private static final class Group { + private final MultipartMembership identity; + private final Map sources = new TreeMap<>(); + private final Map members = new HashMap<>(); + private ScheduledFuture timer; + + private Group(MultipartMembership identity) { + this.identity = identity; + } + + private void add(MultipartMembership member) { + if (!identity.groupId().equals(member.groupId()) || !identity.key().equals(member.key()) || + !identity.deadline().equals(member.deadline()) || identity.groupOrder() != member.groupOrder()) { + throw inconsistent(); + } + members.put(member.source(), member); + if (member.duplicate()) { + return; + } + SourceId previous = sources.putIfAbsent(member.ordinal(), member.source()); + if (previous != null && !previous.equals(member.source())) { + throw inconsistent(); + } + } + + private void validateOriginals() { + for (MultipartMembership member : members.values()) { + if (!member.originalSource().equals(sources.get(member.ordinal()))) { + throw inconsistent(); + } + } + } + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodec.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodec.java new file mode 100644 index 00000000..155f1c4f --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodec.java @@ -0,0 +1,117 @@ +package gr.cytech.sendium.core.storage.pending; + +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; + +import java.io.IOException; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Versioned CBOR representation of accepted sources; has no filesystem or lifecycle side effects. */ +public final class PendingRecordCodec { + public static final int SCHEMA_VERSION = 1; + private final ObjectMapper mapper = new ObjectMapper(CBORFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(256).build()) + .build()); + + public byte[] encode(PendingSourceRecord record) { + Objects.requireNonNull(record, "record"); + ObjectNode envelope = mapper.createObjectNode(); + envelope.put("schemaVersion", SCHEMA_VERSION); + envelope.put("sourceId", record.source().value().toString()); + envelope.put("disposition", record.disposition().name()); + envelope.set("message", record.message().toNode()); + envelope.set("multipart", membership(record.multipart())); + try { + return mapper.writeValueAsBytes(envelope); + } catch (IOException failure) { + throw PendingRecordFields.unsupported(); + } + } + + public PendingSourceRecord decode(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + throw PendingRecordFields.corrupt(); + } + try (var parser = mapper.createParser(bytes)) { + JsonNode envelope = mapper.readTree(parser); + if (envelope == null || !envelope.isObject() || parser.nextToken() != null) { + throw PendingRecordFields.corrupt(); + } + int version = PendingRecordFields.integer(PendingRecordFields.required(envelope, "schemaVersion"), 1, Integer.MAX_VALUE); + if (version != SCHEMA_VERSION) { + throw new PendingRecordException(PendingRecordException.Kind.UNSUPPORTED_VERSION); + } + UUID uuid = uuid(PendingRecordFields.required(envelope, "sourceId")); + var disposition = PendingSourceRecord.Disposition.valueOf( + PendingRecordFields.nonNullText(PendingRecordFields.required(envelope, "disposition"))); + var message = PendingMessageData.fromNode(PendingRecordFields.required(envelope, "message")); + var multipart = readMembership(PendingRecordFields.required(envelope, "multipart")); + return new PendingSourceRecord(new SourceId(uuid), disposition, message, multipart); + } catch (IOException | IllegalArgumentException | DateTimeException failure) { + throw PendingRecordFields.corrupt(); + } + } + + private JsonNode membership(MultipartMembership part) { + if (part == null) { + return mapper.nullNode(); + } + ObjectNode node = mapper.createObjectNode(); + node.put("sourceId", part.source().value().toString()); + node.put("originalSourceId", part.originalSource().value().toString()); + node.put("groupId", part.groupId().toString()); + node.put("ordinal", part.ordinal()); + node.put("deadline", part.deadline().toString()); + node.put("groupOrder", part.groupOrder()); + ObjectNode key = node.putObject("key"); + key.put("accountId", part.key().accountId()); + key.put("systemId", part.key().systemId()); + key.put("sender", part.key().sender()); + key.put("recipient", part.key().recipient()); + key.put("referenceBits", part.key().referenceBits()); + key.put("reference", part.key().reference()); + key.put("total", part.key().total()); + key.put("normalizedUdh", part.key().normalizedUdh()); + return node; + } + + private static MultipartMembership readMembership(JsonNode node) { + if (node.isNull()) { + return null; + } + JsonNode key = PendingRecordFields.required(node, "key"); + var identity = new MultipartKey( + PendingRecordFields.text(PendingRecordFields.required(key, "accountId")), + PendingRecordFields.text(PendingRecordFields.required(key, "systemId")), + PendingRecordFields.text(PendingRecordFields.required(key, "sender")), + PendingRecordFields.text(PendingRecordFields.required(key, "recipient")), + PendingRecordFields.integer(PendingRecordFields.required(key, "referenceBits"), 8, 16), + PendingRecordFields.integer(PendingRecordFields.required(key, "reference"), 0, 65535), + PendingRecordFields.integer(PendingRecordFields.required(key, "total"), 1, 255), + PendingRecordFields.nonNullText(PendingRecordFields.required(key, "normalizedUdh"))); + return new MultipartMembership(new SourceId(uuid(PendingRecordFields.required(node, "sourceId"))), + uuid(PendingRecordFields.required(node, "groupId")), identity, + PendingRecordFields.integer(PendingRecordFields.required(node, "ordinal"), 1, identity.total()), + Instant.parse(PendingRecordFields.nonNullText(PendingRecordFields.required(node, "deadline"))), + PendingRecordFields.integer(PendingRecordFields.required(node, "groupOrder")), + new SourceId(uuid(PendingRecordFields.required(node, "originalSourceId")))); + } + + private static UUID uuid(JsonNode node) { + String value = PendingRecordFields.nonNullText(node); + UUID result = UUID.fromString(value); + if (!result.toString().equalsIgnoreCase(value)) { + throw PendingRecordFields.corrupt(); + } + return result; + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordException.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordException.java new file mode 100644 index 00000000..3101da6c --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordException.java @@ -0,0 +1,23 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; + +/** Codec-specific failure detail without expanding protocol-facing storage reason switches. */ +public final class PendingRecordException extends OutboundStorageException { + private final Kind kind; + + PendingRecordException(Kind kind) { + super(OutboundStage.Role.PENDING, kind == Kind.UNSUPPORTED_VERSION ? Reason.UNSUPPORTED : Reason.UNAVAILABLE, + kind == Kind.UNSUPPORTED_VERSION ? "Unsupported pending schema version (backend=codec)" : "Invalid pending record (backend=codec)"); + this.kind = kind; + } + + public Kind kind() { + return kind; + } + + public enum Kind { + CORRUPT_RECORD, UNSUPPORTED_VERSION + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordFields.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordFields.java new file mode 100644 index 00000000..46fa1aca --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingRecordFields.java @@ -0,0 +1,76 @@ +package gr.cytech.sendium.core.storage.pending; + +import com.fasterxml.jackson.databind.JsonNode; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; + +/** Strict field readers shared by the explicit pending schema and its extension format. */ +final class PendingRecordFields { + private PendingRecordFields() { + } + + static JsonNode required(JsonNode object, String name) { + JsonNode value = object.get(name); + if (value == null) { + throw corrupt(); + } + return value; + } + + static String text(JsonNode value) { + if (value.isNull()) { + return null; + } + if (!value.isTextual()) { + throw corrupt(); + } + return value.textValue(); + } + + static String nonNullText(JsonNode value) { + String result = text(value); + if (result == null) { + throw corrupt(); + } + return result; + } + + static long integer(JsonNode value) { + if (!value.isIntegralNumber() || !value.canConvertToLong()) { + throw corrupt(); + } + return value.longValue(); + } + + static int integer(JsonNode value, int minimum, int maximum) { + long result = integer(value); + if (result < minimum || result > maximum) { + throw corrupt(); + } + return (int) result; + } + + static boolean bool(JsonNode value) { + if (!value.isBoolean()) { + throw corrupt(); + } + return value.booleanValue(); + } + + static double decimal(JsonNode value) { + if (!value.isNumber() || !Double.isFinite(value.doubleValue())) { + throw corrupt(); + } + return value.doubleValue(); + } + + static OutboundStorageException corrupt() { + // Do not attach parser exceptions: their messages can contain SMS bodies or extension values. + return new PendingRecordException(PendingRecordException.Kind.CORRUPT_RECORD); + } + + static OutboundStorageException unsupported() { + return new OutboundStorageException(OutboundStage.Role.PENDING, OutboundStorageException.Reason.UNSUPPORTED, + "Unsupported pending record value (backend=codec)"); + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingSourceRecord.java b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingSourceRecord.java new file mode 100644 index 00000000..105cbcbd --- /dev/null +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/storage/pending/PendingSourceRecord.java @@ -0,0 +1,34 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.util.MessageUtil; + +import java.util.Objects; + +/** Accepted source identity and disposition, independent of any physical storage backend. */ +public record PendingSourceRecord(SourceId source, Disposition disposition, PendingMessageData message, MultipartMembership multipart) { + public PendingSourceRecord(SourceId source, Disposition disposition, PendingMessageData message) { + this(source, disposition, message, null); + } + + public PendingSourceRecord { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(disposition, "disposition"); + Objects.requireNonNull(message, "message"); + if (!source.value().toString().equalsIgnoreCase(message.gatewayId())) { + throw new IllegalArgumentException("Pending source identity must match the gateway identity"); + } + if (multipart != null) { + var payload = message.restore(); + if (disposition != Disposition.HELD || !source.equals(multipart.source()) || + !multipart.key().equals(MultipartKey.from(payload)) || + multipart.ordinal() != MessageUtil.getNumberOfCurrentPart(payload)) { + throw new IllegalArgumentException("Pending multipart membership must match the accepted source"); + } + } + } + + public enum Disposition { + READY, HELD + } +} diff --git a/sendium-core/src/main/java/gr/cytech/sendium/util/MessageUtil.java b/sendium-core/src/main/java/gr/cytech/sendium/util/MessageUtil.java index d9bba2ba..fe82bd00 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/util/MessageUtil.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/util/MessageUtil.java @@ -3,6 +3,8 @@ import com.google.common.base.MoreObjects; import com.google.common.base.Strings; import gr.cytech.sendium.core.message.StandardMessage; +import java.util.HexFormat; +import java.util.Optional; import java.util.Set; public class MessageUtil { @@ -92,7 +94,7 @@ public static int getSmsCntAndTrimForUdh(StandardMessage msg, boolean ccat) { * @return True if the provided message has a supported UDH format */ public static boolean is8BitMessagePart(StandardMessage message) { - return hasUdh(message) && message.binheader.startsWith(UdhPrefix.BIT_8.value); + return concatenation(message).map(part -> part.referenceBits() == 8).orElse(false); } /** @@ -103,7 +105,7 @@ public static boolean is8BitMessagePart(StandardMessage message) { * @return True if the provided message has a supported UDH format */ public static boolean is16BitMessagePart(StandardMessage message) { - return hasUdh(message) && message.binheader.startsWith(UdhPrefix.BIT_16.value); + return concatenation(message).map(part -> part.referenceBits() == 16).orElse(false); } public static boolean hasUdh(StandardMessage message) { @@ -111,40 +113,73 @@ public static boolean hasUdh(StandardMessage message) { } public static String getMessageReference(StandardMessage message) { - String msgRefNum = null; - if (is8BitMessagePart(message)) { - // The digits 6-8 contain the reference number of the 8 bit concatenated message. - msgRefNum = message.binheader.substring(6, 8); - } else if (is16BitMessagePart(message)) { - // The digits 6-10 contain the reference number of the 16 bit concatenated message. - msgRefNum = message.binheader.substring(6, 10); - } - - //The ref can be the same across multiple clients/apis. By prepending the user_id - //we guaranty that if a client uses only one API (e.g smpp or http) the msgRefNum - //will be unique. However, if the same client uses multiple APIs there will be still - //a small possibility to have an overlapping reference. - if (msgRefNum != null) { - msgRefNum = message.owner_id.concat(":").concat(String.valueOf(msgRefNum)); - } - return msgRefNum; + // Legacy runtime reference only; pending admission uses the complete MultipartKey and a group-instance UUID. + return concatenation(message).map(part -> String.valueOf(message.owner_id) + ":" + + (part.referenceBits() == 8 ? HexFormat.of().withUpperCase().toHexDigits((byte) part.reference()) : + HexFormat.of().withUpperCase().toHexDigits((short) part.reference()))).orElse(null); } public static int getNumberOfCurrentPart(StandardMessage message) { - if (is8BitMessagePart(message) || is16BitMessagePart(message)) { - int length = message.binheader.length(); - return Integer.parseInt(message.binheader.substring(length - 2, length), 16); - } - return 0; + return concatenation(message).map(Concatenation::ordinal).orElse(0); } public static int getNumberOfTotalParts(StandardMessage message) { - if (is8BitMessagePart(message)) { - return Integer.parseInt(message.binheader.substring(8, 10), 16); - } else if (is16BitMessagePart(message)) { - return Integer.parseInt(message.binheader.substring(10, 12), 16); + return concatenation(message).map(Concatenation::total).orElse(0); + } + + /** Parses one valid concatenation IE anywhere in a length-checked UDH; malformed headers are unsupported. */ + public static Optional concatenation(StandardMessage message) { + if (message == null || !hasUdh(message)) { + return Optional.empty(); + } + byte[] header; + try { + header = HexFormat.of().parseHex(message.binheader); + } catch (IllegalArgumentException failure) { + return Optional.empty(); + } + if (header.length == 0 || Byte.toUnsignedInt(header[0]) != header.length - 1) { + return Optional.empty(); } - return 0; + Concatenation result = null; + for (int offset = 1; offset < header.length;) { + if (offset + 2 > header.length) { + return Optional.empty(); + } + int identifier = Byte.toUnsignedInt(header[offset]); + int length = Byte.toUnsignedInt(header[offset + 1]); + int start = offset + 2; + int end = start + length; + if (end > header.length) { + return Optional.empty(); + } + if (identifier == 0 || identifier == 8) { + if (result != null || length != (identifier == 0 ? 3 : 4)) { + return Optional.empty(); + } + int total = Byte.toUnsignedInt(header[end - 2]); + int ordinal = Byte.toUnsignedInt(header[end - 1]); + if (total == 0 || ordinal == 0 || ordinal > total) { + return Optional.empty(); + } + if (start >= header.length) { + return Optional.empty(); + } + int reference = Byte.toUnsignedInt(header[start]); + if (identifier == 8) { + if (start + 1 >= header.length) { + return Optional.empty(); + } + reference = (reference << 8) | Byte.toUnsignedInt(header[start + 1]); + } + byte[] normalized = header.clone(); + normalized[end - 1] = 0; + result = new Concatenation(identifier == 0 ? 8 : 16, reference, total, ordinal, + HexFormat.of().withUpperCase().formatHex(normalized)); + } + offset = end; + } + return Optional.ofNullable(result); } public static String validateText(String udh, String msgBody, int msgType, int maxBodyLength) { @@ -349,6 +384,10 @@ private static boolean isValidGsmChar(char ch) { return OneByteCharacters.contains(ch) || TwoByteCharacters.contains(ch); } + /** Normalization preserves all UDH bytes and IE order, except for hex case and the part ordinal. */ + public record Concatenation(int referenceBits, int reference, int total, int ordinal, String normalizedUdh) { + } + public enum UdhPrefix { BIT_8("050003"), BIT_16("060804"); diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinatorTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinatorTest.java index 28b460f7..31aac70d 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinatorTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/DefaultOutboundCoordinatorTest.java @@ -103,7 +103,7 @@ void durableOrAlreadyOpenedStagesAreNotSilentlyAdopted() { } @Test - void successfulHandoffCleansUpInOrderAndLateCompletionCannotRecreateWork() throws Exception { + void retainsSourceUntilRequiredHandoff() throws Exception { try (var h = new Harness().start()) { var delivery = h.delivery(); h.coordinator.complete(new OutboundWork.WorkId(UUID.randomUUID()), new CompletableFuture<>()) @@ -113,6 +113,8 @@ void successfulHandoffCleansUpInOrderAndLateCompletionCannotRecreateWork() throw CompletionStage result = h.coordinator.complete(delivery.work.id(), handoff); assertThat(result.toCompletableFuture()).isNotDone(); assertThat(h.pending.find(delivery.source())).isPresent(); + verify(h.pending, never()).complete(any()); + assertThat(h.coordinator.takeFromRouted("A", Duration.ZERO)).isEmpty(); assertThat((Object) h.coordinator.complete(delivery.work.id(), CompletableFuture.completedStage(null))).isSameAs(result); fails(() -> h.coordinator.returnToRouted(delivery.work), INVALID_TRANSITION); handoff.complete(null); @@ -154,7 +156,7 @@ void failedOrCancelledHandoffRetainsOwnershipForHandoffOnlyRetry(boolean cancel) @ParameterizedTest @ValueSource(strings = {"routed", "pending", "selected", "forget"}) - void cleanupFailureCanBeRetriedWithoutAnotherHandoffOrDispatch(String boundary) throws Exception { + void cleanupRetryDoesNotRepeatProviderAttempt(String boundary) throws Exception { try (var h = new Harness().start()) { var delivery = h.delivery(); var failure = new IllegalStateException("cleanup failed"); @@ -167,6 +169,9 @@ void cleanupFailureCanBeRetriedWithoutAnotherHandoffOrDispatch(String boundary) } var result = h.coordinator.complete(delivery.work.id(), CompletableFuture.completedStage(null)); assertThat(result.toCompletableFuture()).isCompletedExceptionally(); + assertThat(h.coordinator.takeFromRouted("A", Duration.ZERO)).isEmpty(); + assertThat(h.coordinator.selectToRouter(1)).isZero(); + assertThat(h.coordinator.takeForRouting(Duration.ZERO)).isEmpty(); fails(h.coordinator::close, UNAVAILABLE); doCallRealMethod().when(h.routed).complete(delivery.work.id()); doCallRealMethod().when(h.pending).complete(delivery.selected.sources()); @@ -175,6 +180,11 @@ void cleanupFailureCanBeRetriedWithoutAnotherHandoffOrDispatch(String boundary) h.coordinator.complete(delivery.work.id(), new CompletableFuture<>()).toCompletableFuture().join(); assertThat(h.pending.find(delivery.source())).isEmpty(); verify(h.routed, never()).release(any()); + verify(h.routed, times(1)).record(any()); + verify(h.routed, times(boundary.equals("routed") ? 2 : 1)).complete(delivery.work.id()); + verify(h.pending, times(boundary.equals("pending") ? 2 : 1)).complete(delivery.selected.sources()); + verify(h.router, times(boundary.equals("selected") ? 2 : 1)).complete(delivery.selected.id()); + verify(h.routed, times(boundary.equals("forget") ? 2 : 1)).forget(delivery.selected.id()); } } diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/consumer/OutboundCompositionTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/consumer/OutboundCompositionTest.java index e5227774..1f6b2772 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/consumer/OutboundCompositionTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/outbound/consumer/OutboundCompositionTest.java @@ -37,8 +37,12 @@ void externalConsumerUsesCustomMessageTypesThroughTheThreeStagePortsDirectly() t selection, destination.name(), destination.message()); when(router.take(Duration.ZERO)).thenReturn(Optional.of(selected)); when(routed.record(assignment)).thenReturn(work); + when(pending.admit(source, message)).thenReturn(source); + when(pending.find(source)).thenReturn(Optional.of(message)); - pending.admit(source, message); + OutboundWork.SourceId accepted = pending.admit(source, message); + assertThat(accepted).isEqualTo(source); + assertThat(pending.find(accepted).orElseThrow().campaignId).isEqualTo("campaign-7"); CampaignMessage selectedMessage = router.take(Duration.ZERO).orElseThrow().message(); CampaignMessage routedMessage = routed.record(assignment).message(); diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java index a5f6aa93..c6b9dc84 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java @@ -8,6 +8,7 @@ import com.cloudhopper.smpp.pdu.SubmitSm; import com.cloudhopper.smpp.tlv.Tlv; import com.cloudhopper.smpp.type.Address; +import com.google.common.util.concurrent.RateLimiter; import gr.cytech.sendium.conf.PropertyChangeListener; import gr.cytech.sendium.conf.SendiumConfigurationProvider; import gr.cytech.sendium.core.AbstractOutWorker; @@ -50,6 +51,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class SmppClientWorkerTest { @@ -119,31 +122,35 @@ public StandardMessage dequeue(long timeout) throws InterruptedException { @Test void legacyAndCoordinatedMessagesShareTheConfiguredWorkerRateLimit() throws Exception { var worker = new LegacyRateWorker(); + var limiter = mock(RateLimiter.class); var queue = new Queue(); queue.enqueue(messageWithNetwork()); worker.useQueue(queue); var container = mock(ArcContainer.class); when(container.requestContext()).thenReturn(mock(ManagedContext.class)); - try (var arc = mockStatic(Arc.class)) { + try (var limiterFactory = mockStatic(RateLimiter.class); var arc = mockStatic(Arc.class)) { + limiterFactory.when(() -> RateLimiter.create(2.0)).thenReturn(limiter); arc.when(Arc::container).thenReturn(container); - // Start the limiter after mock instrumentation, which otherwise accrues stored permits. worker.configureRate(2); worker.new Worker(0).run(); - } - assertThat(worker.legacySends).isEqualTo(1); - assertThat(worker.permits).isEqualTo(1); - worker.keepOnRunning = true; + assertThat(worker.legacySends).isEqualTo(1); + assertThat(worker.permits).isEqualTo(1); + verify(limiter).acquire(); + worker.keepOnRunning = true; + + for (int i = 0; i < 2; i++) { + worker.submitPreparedCoordinated(messageWithNetwork(), (payload, policy) -> { + throw new AssertionError("Successful submission must not schedule a retry"); + }); + } - for (int i = 0; i < 2; i++) { - worker.submitPreparedCoordinated(messageWithNetwork(), (payload, policy) -> { - throw new AssertionError("Successful submission must not schedule a retry"); - }); + // Verify shared gating directly: a real burst-permitting limiter cannot promise a fixed send interval. + limiterFactory.verify(() -> RateLimiter.create(2.0), times(1)); + verify(limiter, times(3)).acquire(); + assertThat(worker.permits).isEqualTo(3); + assertThat(worker.coordinatedSends).isEqualTo(2); + assertThat(worker.getTransactionsPerSecond()).isEqualTo(2); } - - assertThat(worker.permits).isEqualTo(3); - assertThat(worker.coordinatedSendTime - worker.legacySendTime) - .isGreaterThanOrEqualTo(Duration.ofMillis(800).toNanos()); - assertThat(worker.getTransactionsPerSecond()).isEqualTo(2); } @Test @@ -1135,8 +1142,7 @@ private static StandardMessage messageWithNetwork() { private static final class LegacyRateWorker extends TestSmppClientWorker { private int permits; private int legacySends; - private long legacySendTime; - private long coordinatedSendTime; + private int coordinatedSends; private Runnable afterPermit = () -> { }; private LegacyRateWorker() { @@ -1167,14 +1173,13 @@ protected void applyRateLimit() { @Override public StandardMessage doMessage(int threadIndex, StandardMessage message) { legacySends++; - legacySendTime = System.nanoTime(); keepOnRunning = false; return null; } @Override protected void sendCoordinatedRequest(SubmitSm request) { - coordinatedSendTime = System.nanoTime(); + coordinatedSends++; } } diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/MessagePartsOwnershipTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/MessagePartsOwnershipTest.java index ab6d7793..cb096dae 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/MessagePartsOwnershipTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/MessagePartsOwnershipTest.java @@ -1,10 +1,15 @@ package gr.cytech.sendium.core.smpp.server; import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.pending.MultipartKey; +import gr.cytech.sendium.core.storage.pending.MultipartMembership; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; +import java.util.UUID; +import java.time.Instant; import java.util.concurrent.Callable; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -53,6 +58,34 @@ void duplicateCallbackIdentifiesTheRetainedPartWithoutChangingAssembly() { } } + @Test + void acceptedGroupInstancesDoNotMergeRepeatedOrdinals() { + try (var scheduler = new CapturingScheduler()) { + var listener = new Listener(); + var handler = new MessagePartsHandler<>(listener, 60_000, scheduler); + UUID firstGroup = UUID.randomUUID(); + UUID secondGroup = UUID.randomUUID(); + var first = part(1, "first"); + var repeated = part(1, "repeated"); + var second = part(2, "second"); + handler.addAcceptedPart(first, membership(first, firstGroup, 1)); + handler.addAcceptedPart(repeated, membership(repeated, secondGroup, 1)); + handler.addAcceptedPart(second, membership(second, firstGroup, 2)); + assertThat(listener.groups).hasSize(1); + assertThat(listener.groups.getFirst()).extracting(message -> message.body).containsExactly("first", "second"); + assertThat(listener.duplicate).isNull(); + handler.new DelayedMessagePartsTask(secondGroup.toString()).call(); + assertThat(listener.groups.getLast()).containsExactly(repeated); + handler.stop(); + } + } + + private static MultipartMembership membership(StandardMessage part, UUID group, int ordinal) { + SourceId source = new SourceId(UUID.randomUUID()); + part.serial = source.value().toString(); + return new MultipartMembership(source, group, MultipartKey.from(part), ordinal, Instant.now().plusSeconds(60), 0); + } + private static StandardMessage part(int ordinal, String body) { var message = new StandardMessage(); message.owner_id = "account"; diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppLifecycleAdmissionTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppLifecycleAdmissionTest.java index 0a66c3ab..784be7e5 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppLifecycleAdmissionTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppLifecycleAdmissionTest.java @@ -11,10 +11,14 @@ import gr.cytech.sendium.core.queue.Queue; import gr.cytech.sendium.core.storage.OutboundStage; import gr.cytech.sendium.core.storage.OutboundStorageException; -import gr.cytech.sendium.util.MessageUtil; +import gr.cytech.sendium.core.storage.PendingMessageStore; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentCaptor; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import utils.OutboundIngressFixture; import java.sql.Timestamp; @@ -27,19 +31,21 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.function.Consumer; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; @Timeout(10) class SmppLifecycleAdmissionTest { @Test - void completeSubmissionIsOwnedBeforeStatusOkAndDoesNotEnterLegacyQueues() throws Exception { + void successReferencesAcceptedHandle() throws Exception { try (var lifecycle = new OutboundIngressFixture(1); var worker = new TestWorker()) { worker.setIngressCoordinator(lifecycle.coordinator); var event = submission(null, "hello", 42); @@ -47,7 +53,8 @@ void completeSubmissionIsOwnedBeforeStatusOkAndDoesNotEnterLegacyQueues() throws var source = ArgumentCaptor.forClass(SourceId.class); verify(lifecycle.coordinator).accept(source.capture(), eq(event.pMsg)); assertThat(source.getValue().value()).isEqualTo(UUID.fromString(response.getMessageId())); - assertThat(lifecycle.pending.find(source.getValue()).orElseThrow().serial).isEqualTo(response.getMessageId()); + PendingMessageStore pending = lifecycle.pending; + assertThat(pending.find(source.getValue()).orElseThrow().serial).isEqualTo(response.getMessageId()); }; worker.enqueueIn(event); assertThat(worker.responses).singleElement().satisfies(response -> { @@ -66,8 +73,52 @@ void completeSubmissionIsOwnedBeforeStatusOkAndDoesNotEnterLegacyQueues() throws } } + static Stream admissionFailures() { + return Stream.of(OutboundStorageException.Reason.values()) + .flatMap(reason -> Stream.of(Arguments.of(reason, false), Arguments.of(reason, true))); + } + + @ParameterizedTest + @MethodSource("admissionFailures") + void rejectsPendingAdmissionFailures(OutboundStorageException.Reason reason, boolean held) { + try (var lifecycle = new OutboundIngressFixture(1); var worker = new TestWorker()) { + worker.setIngressCoordinator(lifecycle.coordinator); + var failure = new OutboundStorageException(OutboundStage.Role.PENDING, reason, "private backend details"); + if (held) { + doThrow(failure).when(lifecycle.pending).admitPart(any(), any(), any()); + } else { + doThrow(failure).when(lifecycle.pending).admit(any(), any()); + } + worker.enqueueIn(submission(held ? "0500037F0201" : null, "not accepted", 1)); + int expected = switch (reason) { + case CAPACITY_EXCEEDED -> SmppConstants.STATUS_THROTTLED; + case UNSUPPORTED -> SmppConstants.STATUS_SUBMITFAIL; + default -> SmppConstants.STATUS_SYSERR; + }; + assertThat(worker.responses).singleElement().satisfies(response -> { + assertThat(response.getCommandStatus()).isEqualTo(expected); + assertThat(response.getMessageId()).isNullOrEmpty(); + }); + assertThat(lifecycle.pending.sourcesAfter(null, 1)).isEmpty(); + assertThat(worker.getInEventQueue()).isEmpty(); + assertThat(worker.legacyQueue.isEmpty()).isTrue(); + } + } + + @Test + void unconfirmedPendingHandleIsNeverAcknowledged() { + try (var lifecycle = new OutboundIngressFixture(1); var worker = new TestWorker()) { + worker.setIngressCoordinator(lifecycle.coordinator); + doReturn(null).when(lifecycle.pending).admit(any(), any()); + worker.enqueueIn(submission(null, "not accepted", 1)); + assertThat(worker.responses).singleElement().satisfies(response -> + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR)); + assertThat(worker.legacyQueue.isEmpty()).isTrue(); + } + } + @Test - void multipartPartsAreAcknowledgedIndividuallyThenPublishedWithAllSources() throws Exception { + void multipartSuccessFollowsMembershipAdmission() throws Exception { try (var lifecycle = new OutboundIngressFixture(2); var worker = new TestWorker()) { worker.setIngressCoordinator(lifecycle.coordinator); worker.onResponse = response -> { @@ -76,6 +127,11 @@ void multipartPartsAreAcknowledgedIndividuallyThenPublishedWithAllSources() thro assertThat(accepted.sourceIds.iterator().next().value()).isEqualTo( UUID.fromString(response.getMessageId())); accepted.sourceIds.forEach(source -> assertThat(lifecycle.pending.find(source)).isPresent()); + accepted.sourceIds.forEach(source -> assertThat(lifecycle.pending.findMultipart(source)).hasValueSatisfying(membership -> { + assertThat(membership.source()).isEqualTo(source); + assertThat(membership.key().total()).isEqualTo(2); + assertThat(membership.deadline()).isNotNull(); + })); assertThat(lifecycle.coordinator.selectToRouter(2)).isZero(); }; var first = submission("0500037F0201", "Hello ", 1); @@ -146,7 +202,7 @@ void expiryPublishesEachAcceptedPartIndependently() throws Exception { worker.enqueueIn(first); worker.enqueueIn(third); worker.processQueued(); - worker.expire(first.pMsg); + worker.expire(first.pMsg, lifecycle.pending); var events = worker.queued(); assertThat(events).hasSize(2).allSatisfy(event -> { assertThat(event.sourceIds).hasSize(1); @@ -163,25 +219,69 @@ void expiryPublishesEachAcceptedPartIndependently() throws Exception { } } - @Test - void ignoredDuplicateOrdinalStillReleasesItsAcceptedSourceWithTheOriginalGroup() throws Exception { - try (var lifecycle = new OutboundIngressFixture(3); var worker = new TestWorker()) { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void repeatedOrdinalKeepsFirstPayloadAndCompletesAllAcceptedSources(boolean delayDuplicateNotification) throws Exception { + try (var lifecycle = new OutboundIngressFixture(4); var worker = new TestWorker()) { worker.setIngressCoordinator(lifecycle.coordinator); - worker.enqueueIn(submission("0500037F0201", "first", 1)); - worker.enqueueIn(submission("0500037F0201", "ignored", 2)); - worker.enqueueIn(submission("0500037F0202", "second", 3)); - worker.processQueued(); + worker.enqueueIn(submission("0500037F0301", "first-", 1)); + var repeated = submission("0500037F0301", "repeated", 2); + worker.enqueueIn(repeated); + SourceId repeatedSource = new SourceId(UUID.fromString(repeated.pMsg.serial)); + worker.enqueueIn(submission("0500037F0302", "second-", 3)); + worker.enqueueIn(submission("0500037F0303", "third", 4)); + var raw = worker.queued(); + var duplicateEvents = raw.stream().filter(event -> event.pMsg == repeated.pMsg).toList(); + worker.handleIngressMessages(delayDuplicateNotification + ? raw.stream().filter(event -> event.pMsg != repeated.pMsg).toList() : raw); var ready = worker.getInEventQueue().remove(); assertThat(ready.sourceIds).hasSize(3); - assertThat(ready.pMsg.body).isEqualTo("firstsecond"); - assertThat(ready.pMsg.reassembledParts).hasSize(2); + assertThat(ready.sourceIds).doesNotContain(repeatedSource); + assertThat(ready.pMsg.body).isEqualTo("first-second-third"); + assertThat(ready.pMsg.reassembledParts).hasSize(3); worker.handleIngressMessages(List.of(ready)); lifecycle.coordinator.selectToRouter(1); var selected = lifecycle.coordinator.takeForRouting(Duration.ZERO).orElseThrow(); - assertThat(selected.sources()).hasSize(3); - lifecycle.coordinator.discard(selected.id(), CompletableFuture.completedStage(null)).toCompletableFuture().join(); + assertThat(selected.sources()).hasSize(4).contains(repeatedSource); + var handoff = new CompletableFuture(); + var completion = lifecycle.coordinator.discard(selected.id(), handoff).toCompletableFuture(); + assertThat(completion).isNotDone(); + selected.sources().forEach(source -> assertThat(lifecycle.pending.find(source)).isPresent()); + handoff.complete(null); + completion.join(); ready.sourceIds.forEach(source -> assertThat(lifecycle.pending.find(source)).isEmpty()); - assertThat(worker.responses).hasSize(3); + assertThat(lifecycle.pending.find(repeatedSource)).isEmpty(); + if (delayDuplicateNotification) { + worker.handleIngressMessages(duplicateEvents); + } + assertThat(worker.getInEventQueue()).isEmpty(); + assertThat(lifecycle.coordinator.selectToRouter(1)).isZero(); + assertThat(worker.responses).hasSize(4).allSatisfy(response -> + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK)); + } + } + + @Test + void expiredOriginalOwnsDuplicateWithoutRoutingItsPayload() throws Exception { + try (var lifecycle = new OutboundIngressFixture(2); var worker = new TestWorker()) { + worker.setIngressCoordinator(lifecycle.coordinator); + var original = submission("0500037F0301", "first", 1); + var duplicate = submission("0500037F0301", "ignored", 2); + worker.enqueueIn(original); + worker.enqueueIn(duplicate); + var duplicateSource = new SourceId(UUID.fromString(duplicate.pMsg.serial)); + var raw = worker.queued(); + worker.handleIngressMessages(raw.stream().filter(event -> event.pMsg == original.pMsg).toList()); + worker.expire(original.pMsg, lifecycle.pending); + worker.processQueued(); + assertThat(lifecycle.coordinator.selectToRouter(2)).isEqualTo(1); + var selected = lifecycle.coordinator.takeForRouting(Duration.ZERO).orElseThrow(); + assertThat(selected.sources()).hasSize(2).contains(duplicateSource); + assertThat(selected.message().body).isEqualTo("first"); + lifecycle.coordinator.discard(selected.id(), CompletableFuture.completedStage(null)).toCompletableFuture().join(); + selected.sources().forEach(source -> assertThat(lifecycle.pending.find(source)).isEmpty()); + worker.handleIngressMessages(raw.stream().filter(event -> event.pMsg == duplicate.pMsg).toList()); + assertThat(worker.getInEventQueue()).isEmpty(); } } @@ -213,7 +313,7 @@ void expiryDoesNotClaimAnAcceptedPartStillWaitingInTheIngressQueue() throws Exce worker.enqueueIn(first); worker.processQueued(); worker.enqueueIn(second); - worker.expire(first.pMsg); + worker.expire(first.pMsg, lifecycle.pending); var events = worker.queued(); var unprocessed = events.stream().filter(event -> event.submitSm != null).findFirst().orElseThrow(); var expired = events.stream().filter(event -> event.submitSm == null).findFirst().orElseThrow(); @@ -223,7 +323,7 @@ void expiryDoesNotClaimAnAcceptedPartStillWaitingInTheIngressQueue() throws Exce assertThat(lifecycle.coordinator.takeForRouting(Duration.ZERO).orElseThrow().message().body).isEqualTo("first"); worker.handleIngressMessages(List.of(unprocessed)); assertThat(lifecycle.coordinator.selectToRouter(2)).isZero(); - worker.expire(second.pMsg); + worker.expire(second.pMsg, lifecycle.pending); worker.processQueued(); assertThat(lifecycle.coordinator.selectToRouter(2)).isEqualTo(1); assertThat(lifecycle.coordinator.takeForRouting(Duration.ZERO).orElseThrow().message().body).isEqualTo("second"); @@ -317,8 +417,9 @@ private void processQueued() { handleIngressMessages(queued()); } - private void expire(StandardMessage part) { - messagePartsHandler.new DelayedMessagePartsTask(MessageUtil.getMessageReference(part)).call(); + private void expire(StandardMessage part, PendingMessageStore pending) { + var membership = pending.findMultipart(new SourceId(UUID.fromString(part.serial))).orElseThrow(); + messagePartsHandler.new DelayedMessagePartsTask(membership.groupId().toString()).call(); } @Override diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java index 4c5d6b02..ffc33033 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java @@ -10,26 +10,92 @@ import gr.cytech.sendium.conf.PropertyChangeListener; import gr.cytech.sendium.conf.SendiumConfigurationProvider; import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; import gr.cytech.sendium.core.queue.Queue; import org.junit.jupiter.api.Test; +import utils.OutboundIngressFixture; import java.sql.Timestamp; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.OptionalInt; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; class SmppServerWorkerReassemblyTest { + @Test + void reconstructedGroupAcceptsNewPartWithoutReacknowledgingRecoveredSources() throws Exception { + try (var lifecycle = new OutboundIngressFixture(2)) { + var legacy = new Queue(); + var worker = new TestSmppServerWorker(new TestConfigurationProvider(), legacy); + try { + SourceId firstId = new SourceId(UUID.randomUUID()); + var first = messagePart("0500037F0201", "Hello ", firstId.value().toString()); + lifecycle.coordinator.acceptPart(firstId, first, Duration.ofSeconds(60)); + worker.setIngressCoordinator(lifecycle.coordinator); + worker.reconstructPendingParts(lifecycle.pending, 1); + assertThat(worker.outgoingPdus).isEmpty(); + assertThat(lifecycle.coordinator.selectToRouter(1)).isZero(); + + var second = messagePart("0500037F0202", "world", "assigned at admission"); + worker.enqueueIn(new InEvent<>(second, new SubmitSm(), 1, new Timestamp(System.currentTimeMillis()))); + var acceptedEvent = worker.getInEventQueue().remove(); + worker.handleIngressMessages(List.of(acceptedEvent)); + assertThat(lifecycle.coordinator.selectToRouter(1)).isEqualTo(1); + var selected = lifecycle.coordinator.takeForRouting(Duration.ZERO).orElseThrow(); + assertThat(selected.message().body).isEqualTo("Hello world"); + assertThat(selected.sources()).containsExactlyInAnyOrder(firstId, new SourceId(UUID.fromString(second.serial))); + assertThat(worker.outgoingPdus).singleElement().satisfies(pdu -> + assertThat(((SubmitSmResp) pdu).getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK)); + assertThat(legacy.isEmpty()).isTrue(); + lifecycle.coordinator.discard(selected.id(), CompletableFuture.completedStage(null)).toCompletableFuture().join(); + worker.handleIngressMessages(List.of(acceptedEvent)); + assertThat(lifecycle.coordinator.selectToRouter(1)).isZero(); + assertThatThrownBy(() -> worker.reconstructPendingParts(lifecycle.pending, 1)).isInstanceOf(IllegalStateException.class); + } finally { + worker.messagePartsHandler.stop(); + } + } + } + + @Test + @SuppressWarnings("unchecked") + void failedReconstructionPreventsStartupAndIngressSuccess() { + try (var lifecycle = new OutboundIngressFixture(1)) { + var worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>()); + try { + worker.setIngressCoordinator(lifecycle.coordinator); + PendingMessageStore unreadable = mock(PendingMessageStore.class); + when(unreadable.sourcesAfter(any(), anyInt())).thenThrow(new OutboundStorageException( + OutboundStage.Role.PENDING, OutboundStorageException.Reason.UNAVAILABLE, "Pending records unavailable")); + assertThatThrownBy(() -> worker.reconstructPendingParts(unreadable, 1)).isInstanceOf(OutboundStorageException.class); + assertThatThrownBy(worker::start).isInstanceOf(OutboundStorageException.class); + var message = messagePart(null, "not accepted", "assigned at admission"); + worker.enqueueIn(new InEvent<>(message, new SubmitSm(), 1, new Timestamp(System.currentTimeMillis()))); + assertThat(worker.outgoingPdus).singleElement().satisfies(pdu -> + assertThat(((SubmitSmResp) pdu).getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR)); + assertThat(lifecycle.pending.sourcesAfter(null, 1)).isEmpty(); + } finally { + worker.messagePartsHandler.stop(); + } + } + } + @Test void completeUdhPartsAreReassembledAndRoutedToRouterQueue() throws Exception { Queue routerQueue = new Queue<>(); diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/MemoryPendingMessageStoreContractTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/MemoryPendingMessageStoreContractTest.java new file mode 100644 index 00000000..5b014675 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/MemoryPendingMessageStoreContractTest.java @@ -0,0 +1,36 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.memory.MemoryPendingMessageStore; + +import java.time.Clock; +import java.time.Instant; + +class MemoryPendingMessageStoreContractTest extends PendingMessageStoreContract { + @Override + String expectedBackend() { + return "memory"; + } + + @Override + boolean expectedDurability() { + return false; + } + + @Override + PendingMessageStore createStore(int capacity) { + return createStore(capacity, Clock.systemUTC()); + } + + @Override + PendingMessageStore createStore(int capacity, Clock clock) { + return new MemoryPendingMessageStore<>(capacity, message -> { + try { + return (StandardMessage) message.clone(); + } catch (CloneNotSupportedException failure) { + throw new AssertionError(failure); + } + }, message -> Instant.MIN, clock); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingAdmissionCoordinatorTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingAdmissionCoordinatorTest.java new file mode 100644 index 00000000..184a0dd0 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingAdmissionCoordinatorTest.java @@ -0,0 +1,59 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.DefaultOutboundCoordinator; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.RoutedWorkStore; +import gr.cytech.sendium.core.storage.SelectedRouterStore; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PendingAdmissionCoordinatorTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @SuppressWarnings("unchecked") + void propagatesAcceptedHandle(boolean held) { + PendingMessageStore pending = mock(PendingMessageStore.class); + SelectedRouterStore router = mock(SelectedRouterStore.class); + RoutedWorkStore routed = mock(RoutedWorkStore.class); + when(pending.status()).thenReturn(status(OutboundStage.State.NEW), status(OutboundStage.State.READY)); + when(router.status()).thenReturn(status(OutboundStage.State.NEW), status(OutboundStage.State.READY)); + when(routed.status()).thenReturn(status(OutboundStage.State.NEW), status(OutboundStage.State.READY)); + SourceId source = new SourceId(UUID.randomUUID()); + StandardMessage message = new StandardMessage(); + message.serial = source.value().toString(); + when(pending.admit(source, message)).thenReturn(source); + when(pending.admitHeld(source, message)).thenReturn(source); + try (var coordinator = new DefaultOutboundCoordinator<>(pending, router, routed)) { + coordinator.start(); + assertThat(held ? coordinator.acceptHeld(source, message) : coordinator.accept(source, message)).isEqualTo(source); + if (held) { + verify(pending).admitHeld(source, message); + } else { + verify(pending).admit(source, message); + } + when(pending.admit(any(), any())).thenReturn(null); + assertThatThrownBy(() -> coordinator.accept(new SourceId(UUID.randomUUID()), message)) + .isInstanceOf(OutboundStorageException.class); + when(pending.admit(any(), any())).thenReturn(new SourceId(UUID.randomUUID())); + assertThatThrownBy(() -> coordinator.accept(source, message)) + .isInstanceOf(OutboundStorageException.class); + } + } + + private static OutboundStage.Status status(OutboundStage.State state) { + return new OutboundStage.Status("memory", false, state); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingCompletionFailureTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingCompletionFailureTest.java new file mode 100644 index 00000000..ce8e56ac --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingCompletionFailureTest.java @@ -0,0 +1,152 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.DefaultOutboundCoordinator; +import gr.cytech.sendium.core.outbound.OutboundWork.Destination; +import gr.cytech.sendium.core.outbound.OutboundWork.Routed; +import gr.cytech.sendium.core.outbound.OutboundWork.Selected; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.memory.MemoryPendingMessageStore; +import gr.cytech.sendium.core.storage.memory.MemoryRoutedWorkStore; +import gr.cytech.sendium.core.storage.memory.MemorySelectedRouterStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static gr.cytech.sendium.core.storage.OutboundStorageException.Reason.UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** Faults wrap real memory stores; this fixture makes no persistence or process-restart claim. */ +@Timeout(10) +class PendingCompletionFailureTest { + @Test + void uncertainAbsenceFailsCompletion() throws Exception { + try (var fixture = new Fixture()) { + var source = fixture.sources.iterator().next(); + var unavailable = new OutboundStorageException(OutboundStage.Role.PENDING, UNAVAILABLE, "Pending storage unavailable"); + doThrow(unavailable).when(fixture.pending).complete(fixture.sources); + + var result = fixture.coordinator.complete(fixture.work.id(), CompletableFuture.completedStage(null)); + + assertThatThrownBy(() -> result.toCompletableFuture().join()) + .isInstanceOf(CompletionException.class).hasCause(unavailable); + assertThat(fixture.pending.find(source)).isPresent(); + verify(fixture.router, never()).complete(any()); + verify(fixture.routed, never()).forget(any()); + fixture.assertNotDispatchable(); + + doCallRealMethod().when(fixture.pending).complete(fixture.sources); + fixture.coordinator.complete(fixture.work.id(), new CompletableFuture<>()).toCompletableFuture().join(); + fixture.sources.forEach(id -> assertThat(fixture.pending.find(id)).isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void resumesPartialSourceSetCompletion(boolean removeAllBeforeFailure) throws Exception { + try (var fixture = new Fixture()) { + SourceId first = fixture.sources.iterator().next(); + doAnswer(invocation -> { + // Model an interrupted batch or a committed delete whose acknowledgement was lost. + Set removed = removeAllBeforeFailure ? fixture.sources : Set.of(first); + doCallRealMethod().when(fixture.pending).complete(removed); + fixture.pending.complete(removed); + throw new OutboundStorageException(OutboundStage.Role.PENDING, UNAVAILABLE, "Completion result unavailable"); + }).when(fixture.pending).complete(fixture.sources); + + var result = fixture.coordinator.complete(fixture.work.id(), CompletableFuture.completedStage(null)); + assertThat(result.toCompletableFuture()).isCompletedExceptionally(); + assertThat(fixture.pending.find(first)).isEmpty(); + long retained = fixture.sources.stream().filter(id -> fixture.pending.find(id).isPresent()).count(); + assertThat(retained).isEqualTo(removeAllBeforeFailure ? 0 : fixture.sources.size() - 1); + verify(fixture.router, never()).complete(any()); + verify(fixture.routed, never()).forget(any()); + fixture.assertNotDispatchable(); + + // Even apparent absence does not let the coordinator bypass a failed completion boundary. + var unavailable = new OutboundStorageException(OutboundStage.Role.PENDING, UNAVAILABLE, "Pending storage unavailable"); + doThrow(unavailable).when(fixture.pending).complete(fixture.sources); + var retry = fixture.coordinator.complete(fixture.work.id(), new CompletableFuture<>()); + assertThatThrownBy(() -> retry.toCompletableFuture().join()).hasCause(unavailable); + verify(fixture.router, never()).complete(any()); + fixture.assertNotDispatchable(); + + doCallRealMethod().when(fixture.pending).complete(fixture.sources); + fixture.coordinator.complete(fixture.work.id(), new CompletableFuture<>()).toCompletableFuture().join(); + fixture.sources.forEach(id -> assertThat(fixture.pending.find(id)).isEmpty()); + verify(fixture.routed, times(1)).complete(fixture.work.id()); + verify(fixture.router, times(1)).complete(fixture.selected.id()); + verify(fixture.routed, times(1)).forget(fixture.selected.id()); + verify(fixture.routed, times(1)).record(any()); + } + } + + private static final class Fixture implements AutoCloseable { + private final MemoryPendingMessageStore pending = spy(new MemoryPendingMessageStore<>(3, Fixture::copy)); + private final MemorySelectedRouterStore router = spy(new MemorySelectedRouterStore<>(pending, 1)); + private final MemoryRoutedWorkStore routed = spy(new MemoryRoutedWorkStore<>(1, Fixture::copy)); + private final DefaultOutboundCoordinator coordinator = new DefaultOutboundCoordinator<>(pending, router, routed); + private final Set sources = new LinkedHashSet<>(); + private final Selected selected; + private final Routed work; + + private Fixture() throws InterruptedException { + coordinator.start(); + for (int part = 1; part <= 3; part++) { + SourceId id = new SourceId(UUID.randomUUID()); + StandardMessage message = new StandardMessage(); + message.serial = id.value().toString(); + message.body = "part-" + part; + sources.add(coordinator.acceptHeld(id, message)); + } + StandardMessage aggregate = new StandardMessage(); + aggregate.body = "aggregate"; + coordinator.makeHeldReady(sources, aggregate); + assertThat(coordinator.selectToRouter(1)).isEqualTo(1); + selected = coordinator.takeForRouting(Duration.ZERO).orElseThrow(); + coordinator.recordToRouted(selected, new Destination<>("provider", selected.message())); + work = coordinator.takeFromRouted("provider", Duration.ZERO).orElseThrow(); + } + + private void assertNotDispatchable() throws InterruptedException { + assertThat(coordinator.selectToRouter(1)).isZero(); + assertThat(coordinator.takeForRouting(Duration.ZERO)).isEmpty(); + assertThat(coordinator.takeFromRouted("provider", Duration.ZERO)).isEmpty(); + } + + private static StandardMessage copy(StandardMessage message) { + try { + return (StandardMessage) message.clone(); + } catch (CloneNotSupportedException failure) { + throw new AssertionError(failure); + } + } + + @Override + public void close() { + // Let failed assertions report their cause instead of leaving an unretryable test-owned cleanup behind. + doCallRealMethod().when(pending).complete(sources); + coordinator.complete(work.id(), CompletableFuture.completedStage(null)).toCompletableFuture().join(); + coordinator.close(); + } + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMessageStoreContract.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMessageStoreContract.java new file mode 100644 index 00000000..9028ff9b --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMessageStoreContract.java @@ -0,0 +1,390 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +import static gr.cytech.sendium.core.storage.OutboundStorageException.Reason.CAPACITY_EXCEEDED; +import static gr.cytech.sendium.core.storage.OutboundStorageException.Reason.UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Backend-neutral source admission/lookup tests; durable implementations can extend this fixture. */ +abstract class PendingMessageStoreContract { + abstract PendingMessageStore createStore(int capacity); + + abstract PendingMessageStore createStore(int capacity, Clock clock); + + abstract String expectedBackend(); + + abstract boolean expectedDurability(); + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void returnsRetrievableAcceptedHandle(boolean held) { + try (var pending = createStore(2)) { + pending.open(); + SourceId source = source(1); + StandardMessage message = message(source, "accepted"); + SourceId accepted = held ? pending.admitHeld(source, message) : pending.admit(source, message); + assertThat(accepted).isEqualTo(source); + message.body = "changed by caller"; + assertThat(pending.find(accepted).orElseThrow().body).isEqualTo("accepted"); + StandardMessage recovered = pending.find(accepted).orElseThrow(); + recovered.body = "changed by lookup caller"; + assertThat(pending.find(accepted).orElseThrow().body).isEqualTo("accepted"); + assertThat(pending.find(source(2))).isEmpty(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void sameSourceRetryPreservesAcceptedRecord(boolean held) { + try (var pending = createStore(1)) { + pending.open(); + SourceId source = source(1); + SourceId accepted = held ? pending.admitHeld(source, message(source, "first")) : pending.admit(source, message(source, "first")); + assertThat(pending.admit(source, message(source, "replacement"))).isEqualTo(accepted); + assertThat(pending.admitHeld(source, message(source, "replacement held"))).isEqualTo(accepted); + assertThat(pending.find(source).orElseThrow().body).isEqualTo("first"); + assertThat(pending.sourcesAfter(null, 2)).containsExactly(source); + assertThatThrownBy(() -> pending.admit(source(2), message(source(2), "full"))) + .isInstanceOfSatisfying(OutboundStorageException.class, failure -> assertThat(failure.reason()).isEqualTo(CAPACITY_EXCEEDED)); + } + } + + @Test + void recoveryTraversalIsBounded() { + try (var pending = createStore(23)) { + pending.open(); + var expected = new ArrayList(); + for (int index = 23; index > 0; index--) { + SourceId id = source(index); + expected.add(id); + if (index % 2 == 0) { + pending.admitHeld(id, message(id, "held")); + } else { + pending.admit(id, message(id, "ready")); + } + } + expected.sort(Comparator.comparing(SourceId::value)); + var recovered = new ArrayList(); + SourceId cursor = null; + for (int pageNumber = 0; pageNumber < 6; pageNumber++) { + List page = pending.sourcesAfter(cursor, 5); + assertThat(page).hasSizeLessThanOrEqualTo(5); + if (page.isEmpty()) { + break; + } + page.forEach(id -> assertThat(pending.find(id)).isPresent()); + recovered.addAll(page); + cursor = page.getLast(); + } + assertThat(recovered).containsExactlyElementsOf(expected); + assertThat(pending.sourcesAfter(cursor, 5)).isEmpty(); + } + } + + @Test + void removedCursorStillMakesProgress() { + try (var pending = createStore(3)) { + pending.open(); + for (int index = 1; index <= 3; index++) { + pending.admit(source(index), message(source(index), "source")); + } + var first = pending.sourcesAfter(null, 1); + pending.complete(Set.of(first.getFirst())); + assertThat(pending.sourcesAfter(first.getFirst(), 2)).containsExactly(source(2), source(3)); + assertThatThrownBy(() -> pending.sourcesAfter(null, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> pending.sourcesAfter(null, -1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> first.add(source(4))).isInstanceOf(UnsupportedOperationException.class); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void completionOfKnownAbsentSourceIsIdempotent(boolean held) { + try (var pending = createStore(2)) { + pending.open(); + SourceId completed = source(1); + SourceId unrelated = source(2); + if (held) { + pending.admitHeld(completed, message(completed, "held")); + } else { + pending.admit(completed, message(completed, "ready")); + } + pending.admit(unrelated, message(unrelated, "unrelated")); + pending.complete(Set.of(completed)); + assertThat(pending.find(completed)).isEmpty(); + pending.complete(Set.of(completed)); + pending.complete(Set.of(source(3))); + assertThat(pending.find(unrelated).orElseThrow().body).isEqualTo("unrelated"); + assertThat(pending.sourcesAfter(null, 3)).containsExactly(unrelated); + pending.admit(source(4), message(source(4), "replacement")); + } + } + + @Test + void completionRequiresReadyBackendEvenForMissingHandles() { + try (var pending = createStore(1)) { + assertUnavailable(() -> pending.complete(Set.of(source(1)))); + pending.open(); + pending.complete(Set.of(source(1))); + pending.close(); + assertUnavailable(() -> pending.complete(Set.of(source(1)))); + } + } + + @Test + void repeatedOrdinalOwnsDistinctSource() { + try (var pending = createStore(2)) { + pending.open(); + var original = multipart("0500037F0301"); + var first = acceptPart(pending, original); + var repeated = acceptPart(pending, multipart("0500037F0301")); + assertThat(repeated.source()).isNotEqualTo(first.source()); + assertThat(repeated.groupId()).isEqualTo(first.groupId()); + assertThat(repeated.originalSource()).isEqualTo(first.source()); + assertThat(repeated.duplicate()).isTrue(); + var retry = multipart("0500037F0302"); + retry.serial = original.serial; + retry.body = "replacement must not win"; + assertThat(pending.admitPart(first.source(), retry, Duration.ofMinutes(5))).isEqualTo(first); + assertThat(pending.find(first.source()).orElseThrow().binheader).isEqualTo("0500037F0301"); + assertThat(pending.findMultipart(first.source())).contains(first); + assertThat(pending.findMultipart(repeated.source())).contains(repeated); + assertThat(pending.sourcesAfter(null, 3)).hasSize(2); + assertThatThrownBy(() -> acceptPart(pending, multipart("0500037F0303"))) + .isInstanceOfSatisfying(OutboundStorageException.class, failure -> assertThat(failure.reason()).isEqualTo(CAPACITY_EXCEEDED)); + } + } + + @Test + void assignsOldestEligibleGroup() { + var clock = Clock.fixed(Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC); + try (var pending = createStore(5, clock)) { + pending.open(); + var a = acceptPart(pending, multipart("0500037F0301")); + var b = acceptPart(pending, multipart("0500037F0301")); + assertThat(a.groupOrder()).isEqualTo(b.groupOrder()); + var c = acceptPart(pending, multipart("0500037F0302")); + var d = acceptPart(pending, multipart("0500037F0303")); + assertThat(c.groupId()).isEqualTo(a.groupId()).isEqualTo(b.groupId()); + assertThat(d.groupId()).isEqualTo(a.groupId()); + assertThat(c.groupOrder()).isEqualTo(a.groupOrder()); + assertThat(d.groupOrder()).isEqualTo(a.groupOrder()); + var next = acceptPart(pending, multipart("0500037F0302")); + assertThat(next.groupId()).isNotEqualTo(a.groupId()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"account", "system", "sender", "recipient", "referenceBits", "reference", "total", "udh"}) + void isolatesMultipartIdentityComponents(String component) { + try (var pending = createStore(2)) { + pending.open(); + var first = acceptPart(pending, multipart("0500037F0301")); + var other = multipart("0500037F0302"); + switch (component) { + case "account" -> other.owner_id = "another-account"; + case "system" -> other.systemId = "another-system"; + case "sender" -> other.from = "another-sender"; + case "recipient" -> other.to = "another-recipient"; + case "referenceBits" -> other.binheader = "060804007F0302"; + case "reference" -> other.binheader = "050003800302"; + case "total" -> other.binheader = "0500037F0402"; + case "udh" -> other.binheader = "0B00037F0302050423F023F1"; + default -> throw new AssertionError(component); + } + assertThat(acceptPart(pending, other).groupId()).isNotEqualTo(first.groupId()); + } + } + + @Test + void laterPartPreservesAbsoluteDeadline() { + var clock = new MutableClock(); + try (var pending = createStore(2, clock)) { + pending.open(); + var first = acceptPart(pending, multipart("0500037F0301")); + clock.now.set(clock.instant().plusSeconds(50)); + StandardMessage second = multipart("0500037F0302"); + SourceId source = new SourceId(UUID.fromString(second.serial)); + var later = pending.admitPart(source, second, Duration.ofMinutes(5)); + assertThat(later.groupId()).isEqualTo(first.groupId()); + assertThat(later.deadline()).isEqualTo(Instant.parse("2026-10-20T12:01:00Z")); + } + } + + @Test + void doesNotJoinExpiredGroup() { + var clock = new MutableClock(); + try (var pending = createStore(2, clock)) { + pending.open(); + var first = acceptPart(pending, multipart("0500037F0301")); + clock.now.set(first.deadline()); + var second = acceptPart(pending, multipart("0500037F0302")); + assertThat(second.groupId()).isNotEqualTo(first.groupId()); + assertThat(second.deadline()).isEqualTo(first.deadline().plusSeconds(60)); + } + } + + @Test + void assignsConcurrentOrdinalsAtomically() throws Exception { + try (var pending = createStore(32); var executor = Executors.newVirtualThreadPerTaskExecutor()) { + pending.open(); + var tasks = new ArrayList>(); + for (int index = 0; index < 32; index++) { + tasks.add(executor.submit(() -> acceptPart(pending, multipart("0500037F0301")))); + } + var groups = new java.util.HashSet(); + for (var task : tasks) { + var accepted = task.get(5, java.util.concurrent.TimeUnit.SECONDS); + groups.add(accepted.groupId()); + assertThat(pending.findMultipart(accepted.source())).contains(accepted); + } + assertThat(pending.sourcesAfter(null, 32)).hasSize(32); + assertThat(groups).hasSize(1); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void publishedOrCompletedGroupCannotAbsorbAnotherPart(boolean completed) { + try (var pending = createStore(2)) { + pending.open(); + var first = acceptPart(pending, multipart("0500037F0301")); + if (completed) { + pending.complete(Set.of(first.source())); + } else { + pending.makeHeldReady(Set.of(first.source()), pending.find(first.source()).orElseThrow()); + } + var later = acceptPart(pending, multipart("0500037F0302")); + assertThat(later.groupId()).isNotEqualTo(first.groupId()); + } + } + + @Test + void malformedPartDoesNotConsumeCapacityOrCreateAcceptedMembership() { + try (var pending = createStore(1)) { + pending.open(); + StandardMessage invalid = multipart("0500037F0300"); + SourceId source = new SourceId(UUID.fromString(invalid.serial)); + assertThatThrownBy(() -> pending.admitPart(source, invalid, Duration.ofSeconds(60))) + .isInstanceOfSatisfying(OutboundStorageException.class, failure -> + assertThat(failure.reason()).isEqualTo(OutboundStorageException.Reason.UNSUPPORTED)); + assertThat(pending.find(source)).isEmpty(); + assertThat(pending.findMultipart(source)).isEmpty(); + assertThat(pending.sourcesAfter(null, 1)).isEmpty(); + assertThat(acceptPart(pending, multipart("0500037F0301"))).isNotNull(); + } + } + + @Test + void publicationCannotMergeSeparateOrIncompleteAssignedGroups() { + try (var pending = createStore(4)) { + pending.open(); + var first = acceptPart(pending, multipart("0500037F0301")); + var repeated = acceptPart(pending, multipart("0500037F0301")); + var second = acceptPart(pending, multipart("0500037F0302")); + assertThatThrownBy(() -> pending.makeHeldReady(Set.of(first.source(), repeated.source(), second.source()), new StandardMessage())) + .isInstanceOfSatisfying(OutboundStorageException.class, failure -> + assertThat(failure.reason()).isEqualTo(OutboundStorageException.Reason.INVALID_TRANSITION)); + assertThatThrownBy(() -> pending.makeHeldReady(Set.of(first.source(), second.source()), new StandardMessage())) + .isInstanceOf(OutboundStorageException.class); + var third = acceptPart(pending, multipart("0500037F0303")); + pending.makeHeldReady(Set.of(first.source(), second.source(), third.source()), new StandardMessage()); + assertThat(pending.findMultipart(repeated.source())).contains(repeated); + } + } + + private static MultipartMembership acceptPart(PendingMessageStore pending, StandardMessage message) { + return pending.admitPart(new SourceId(UUID.fromString(message.serial)), message, Duration.ofSeconds(60)); + } + + private static StandardMessage multipart(String udh) { + var message = new StandardMessage(); + message.serial = UUID.randomUUID().toString(); + message.owner_id = "account"; + message.systemId = "system"; + message.from = "sender"; + message.to = "recipient"; + message.body = "accepted part"; + message.binheader = udh; + return message; + } + + private static final class MutableClock extends Clock { + private final AtomicReference now = new AtomicReference<>(Instant.parse("2026-10-20T12:00:00Z")); + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return Clock.fixed(instant(), zone); + } + + @Override + public Instant instant() { + return now.get(); + } + } + + @Test + void reportsBackendIdentityAndReadiness() { + try (var pending = createStore(1)) { + assertThat(pending.status().backend()).isEqualTo(expectedBackend()); + assertThat(pending.status().durable()).isEqualTo(expectedDurability()); + assertThat(pending.status().state()).isEqualTo(OutboundStage.State.NEW); + assertUnavailable(() -> pending.find(source(1))); + assertUnavailable(() -> pending.sourcesAfter(null, 1)); + assertUnavailable(() -> pending.findMultipart(source(1))); + assertUnavailable(() -> pending.admit(source(1), message(source(1), "source"))); + pending.open(); + assertThat(pending.status().state()).isEqualTo(OutboundStage.State.READY); + pending.close(); + assertThat(pending.status().state()).isEqualTo(OutboundStage.State.CLOSED); + assertUnavailable(() -> pending.find(source(1))); + assertUnavailable(() -> pending.sourcesAfter(null, 1)); + assertUnavailable(() -> pending.findMultipart(source(1))); + } + } + + private static void assertUnavailable(Runnable action) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(OutboundStorageException.class, failure -> { + assertThat(failure.stage()).isEqualTo(OutboundStage.Role.PENDING); + assertThat(failure.reason()).isEqualTo(UNAVAILABLE); + }); + } + + private static SourceId source(int value) { + return new SourceId(new UUID(0, value)); + } + + private static StandardMessage message(SourceId source, String body) { + var message = new StandardMessage(); + message.serial = source.value().toString(); + message.body = body; + return message; + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecoveryTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecoveryTest.java new file mode 100644 index 00000000..9a9203f6 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingMultipartRecoveryTest.java @@ -0,0 +1,471 @@ +package gr.cytech.sendium.core.storage.pending; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.OutboundStage; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.PendingMessageStore; +import gr.cytech.sendium.core.storage.memory.MemoryPendingMessageStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PendingMultipartRecoveryTest { + @Test + void reconstructsRecordedGroupAssignments() { + try (var fixture = new Fixture()) { + SourceId a = fixture.part(1, 3, "A", new SourceId(new UUID(0, 20))); + SourceId b = fixture.part(1, 3, "B", new SourceId(new UUID(0, 10))); + SourceId c = fixture.part(2, 3, "C"); + SourceId d = fixture.part(3, 3, "D"); + fixture.recover(2); + assertThat(fixture.projections.keySet()).containsExactly(Set.of(a, b, c, d)); + assertThat(fixture.projections.get(Set.of(a, b, c, d)).body).isEqualTo("ACD"); + assertThat(fixture.bindings).containsEntry(b, Set.of(a, b, c, d)); + assertThat(fixture.port.find(b)).isPresent(); + assertThat(fixture.recovery.isReady()).isTrue(); + verify(fixture.port, never()).admitPart(any(), any(), any()); + } + } + + @Test + void missingDuplicateOriginalFailsBeforePublishingAnyGroup() throws Exception { + try (var fixture = new Fixture()) { + fixture.part(1, 1, "otherwise ready"); + fixture.part(1, 3, "original"); + SourceId duplicate = fixture.part(1, 3, "duplicate"); + fixture.seal(); + var mapper = new ObjectMapper(new CBORFactory()); + ObjectNode record = (ObjectNode) mapper.readTree(fixture.records.get(duplicate)); + ((ObjectNode) record.get("multipart")).put("originalSourceId", UUID.randomUUID().toString()); + fixture.records.put(duplicate, mapper.writeValueAsBytes(record)); + assertThatThrownBy(() -> fixture.recover(1)).isInstanceOf(OutboundStorageException.class); + assertThat(fixture.projections).isEmpty(); + assertThat(fixture.recovery.isReady()).isFalse(); + } + } + + @Test + void downtimeCountsTowardDeadline() { + try (var fixture = new Fixture()) { + SourceId source = fixture.part(1, 3, "part"); + fixture.clock.now = fixture.clock.now.plusSeconds(50); + fixture.recover(5); + assertThat(fixture.projections).isEmpty(); + assertThat(fixture.scheduler.active()).singleElement().satisfies(task -> + assertThat(task.due).isEqualTo(Instant.parse("2026-10-20T12:01:00Z"))); + assertThat(fixture.payloadReads).isZero(); + fixture.clock.now = fixture.clock.now.plusSeconds(10); + fixture.scheduler.runDue(); + assertThat(fixture.projections).containsKey(Set.of(source)); + } + } + + @Test + void releasesExpiredReceivedSources() { + try (var fixture = new Fixture()) { + SourceId first = fixture.part(1, 3, "first"); + SourceId duplicate = fixture.part(1, 3, "must not replace first"); + SourceId third = fixture.part(3, 3, "third"); + fixture.clock.now = fixture.clock.now.plusSeconds(120); + fixture.recover(1); + assertThat(fixture.projections.keySet()).containsExactlyInAnyOrder(Set.of(first, duplicate), Set.of(third)); + assertThat(fixture.projections.get(Set.of(first, duplicate)).binheader).isEqualTo("0500037F0301"); + assertThat(fixture.projections.get(Set.of(first, duplicate)).body).isEqualTo("first"); + assertThat(fixture.scheduler.active()).isEmpty(); + } + } + + @Test + void aggregateOwnsSourcesExclusively() { + try (var fixture = new Fixture()) { + SourceId first = fixture.part(1, 2, "one"); + SourceId second = fixture.part(2, 2, "two"); + fixture.clock.now = fixture.clock.now.plusSeconds(120); + fixture.recover(1); + Set sources = Set.of(first, second); + assertThat(fixture.bindings).containsEntry(first, sources).containsEntry(second, sources); + assertThat(fixture.projections).hasSize(1); + assertThat(fixture.projections.get(sources).reassembledParts) + .containsExactly(first.value().toString(), second.value().toString()); + fixture.recovery.accepted(first); + fixture.recovery.accepted(second); + assertThat(fixture.projections).hasSize(1); + assertThat(fixture.records).hasSize(2); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void replayedPublicationDoesNotDuplicateWork(boolean commitBeforeFailure) { + try (var fixture = new Fixture()) { + SourceId first = fixture.part(1, 2, "one"); + SourceId second = fixture.part(2, 2, "two"); + fixture.seal(); + AtomicBoolean fail = new AtomicBoolean(true); + BiConsumer, StandardMessage> publish = (sources, message) -> { + if (fail.getAndSet(false)) { + if (commitBeforeFailure) { + message.body = "first prepared value"; + fixture.port.makeHeldReady(sources, message); + } + throw new OutboundStorageException(OutboundStage.Role.PENDING, + OutboundStorageException.Reason.UNAVAILABLE, "Publication unavailable"); + } + fixture.port.makeHeldReady(sources, message); + }; + fixture.recovery = new PendingMultipartRecovery<>(fixture.port, fixture.clock, fixture.scheduler, publish); + assertThatThrownBy(() -> fixture.recovery.reconstruct(1)).isInstanceOf(OutboundStorageException.class); + assertThat(fixture.recovery.isReady()).isFalse(); + fixture.recovery.reconstruct(1); + fixture.recovery.reconstruct(2); + assertThat(fixture.projections).hasSize(1); + assertThat(fixture.projections.get(Set.of(first, second)).body) + .isEqualTo(commitBeforeFailure ? "first prepared value" : "onetwo"); + assertThat(fixture.committedPublications).isEqualTo(1); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void invalidRecordPreventsRecoveryReadiness(boolean unknownVersion) throws Exception { + try (var fixture = new Fixture()) { + fixture.part(1, 2, "one"); + SourceId second = fixture.part(2, 2, "two"); + fixture.seal(); + if (unknownVersion) { + var cbor = new ObjectMapper(new CBORFactory()); + var node = (ObjectNode) cbor.readTree(fixture.records.get(second)); + node.put("schemaVersion", 999); + fixture.records.put(second, cbor.writeValueAsBytes(node)); + } else { + fixture.records.put(second, new byte[]{(byte) 255}); + } + fixture.recovery = new PendingMultipartRecovery<>(fixture.port, fixture.clock, fixture.scheduler, fixture.port::makeHeldReady); + assertThatThrownBy(() -> fixture.recovery.reconstruct(1)).isInstanceOf(PendingRecordException.class); + assertThat(fixture.recovery.isReady()).isFalse(); + assertThat(fixture.records).hasSize(2); + assertThat(fixture.projections).isEmpty(); + assertThat(fixture.scheduler.active()).isEmpty(); + } + } + + @Test + void reconstructsFromBoundedPages() { + try (var fixture = new Fixture()) { + for (int index = 0; index < 23; index++) { + fixture.part(1, 2, "held-" + index); + } + fixture.recover(5); + assertThat(fixture.maximumPage).isLessThanOrEqualTo(5); + assertThat(fixture.pages).isEqualTo(6); + assertThat(fixture.payloadReads).isZero(); + assertThat(fixture.scheduler.active()).hasSize(1); + } + } + + @Test + void restartRebuildsRatherThanRememberingAnEarlierRelease() { + try (var original = new Fixture(); var restarted = new Fixture()) { + SourceId first = original.part(1, 2, "one"); + SourceId second = original.part(2, 2, "two"); + original.seal(); + original.port.makeHeldReady(Set.of(first), original.port.find(first).orElseThrow()); + assertThat(original.projections).containsKey(Set.of(first)); + + // Only accepted-source CBOR is retained; prior runtime release decisions are deliberately not copied. + restarted.records.putAll(original.records); + original.groups.forEach((group, sources) -> restarted.groups.put(group, List.copyOf(sources))); + restarted.sealed = true; + restarted.recover(1); + assertThat(restarted.projections.keySet()).containsExactly(Set.of(first, second)); + assertThat(restarted.projections.get(Set.of(first, second)).body).isEqualTo("onetwo"); + } + } + + @Test + void expiryRetriesInterruptedPublicationWithinTheRunningProcess() { + try (var fixture = new Fixture()) { + SourceId first = fixture.part(1, 3, "first"); + SourceId third = fixture.part(3, 3, "third"); + fixture.recover(1); + AtomicBoolean unavailable = new AtomicBoolean(true); + doAnswer(call -> { + fixture.publish(call.getArgument(0), call.getArgument(1)); + if (unavailable.getAndSet(false)) { + throw new OutboundStorageException(OutboundStage.Role.PENDING, + OutboundStorageException.Reason.UNAVAILABLE, "Publication acknowledgement unavailable"); + } + return null; + }).when(fixture.port).makeHeldReady(any(), any()); + fixture.clock.now = fixture.clock.now.plusSeconds(60); + fixture.scheduler.runDue(); + assertThat(fixture.recovery.isReady()).isTrue(); + assertThat(fixture.projections).hasSize(1); + fixture.clock.now = fixture.clock.now.plusSeconds(1); + fixture.scheduler.runDue(); + assertThat(fixture.projections.keySet()).containsExactlyInAnyOrder(Set.of(first), Set.of(third)); + assertThat(fixture.committedPublications).isEqualTo(2); + assertThat(fixture.scheduler.active()).isEmpty(); + } + } + + @Test + void memoryGroupLookupRetainsOnlyUncompletedSourceHandles() { + try (var pending = new MemoryPendingMessageStore(2, value -> PendingMessageData.capture(value).restore())) { + pending.open(); + var first = new StandardMessage(); + var firstId = new SourceId(UUID.randomUUID()); + first.serial = firstId.value().toString(); + first.binheader = "0500037F0201"; + var membership = pending.admitPart(firstId, first, Duration.ofSeconds(60)); + var second = PendingMessageData.capture(first).restore(); + var secondId = new SourceId(UUID.randomUUID()); + second.serial = secondId.value().toString(); + second.binheader = "0500037F0202"; + pending.admitPart(secondId, second, Duration.ofSeconds(60)); + pending.makeHeldReady(Set.of(firstId, secondId), PendingMultipartRecovery.assemble(List.of(first, second))); + assertThat(pending.multipartSources(membership.groupId())).containsExactly(firstId, secondId); + pending.complete(Set.of(firstId)); + assertThat(pending.multipartSources(membership.groupId())).containsExactly(secondId); + pending.complete(Set.of(secondId)); + assertThat(pending.multipartSources(membership.groupId())).isEmpty(); + } + } + + @Test + void corruptionAfterStartupStopsRecordDrivenProcessing() { + try (var fixture = new Fixture()) { + SourceId source = fixture.part(1, 3, "retained"); + fixture.recover(1); + fixture.records.put(source, new byte[]{(byte) 255}); + assertThatThrownBy(() -> fixture.recovery.accepted(source)).isInstanceOf(PendingRecordException.class); + assertThat(fixture.recovery.isReady()).isFalse(); + assertThat(fixture.scheduler.active()).isEmpty(); + assertThat(fixture.projections).isEmpty(); + assertThat(fixture.records).containsKey(source); + } + } + + @Test + void aMissingGroupIndexCannotSilentlyHideAcceptedSources() { + try (var fixture = new Fixture()) { + SourceId source = fixture.part(1, 3, "retained"); + fixture.seal(); + fixture.groups.clear(); + fixture.recovery = new PendingMultipartRecovery<>(fixture.port, fixture.clock, fixture.scheduler, fixture.port::makeHeldReady); + assertThatThrownBy(() -> fixture.recovery.reconstruct(1)).isInstanceOf(OutboundStorageException.class); + assertThat(fixture.recovery.isReady()).isFalse(); + assertThat(fixture.records).containsKey(source); + assertThat(fixture.projections).isEmpty(); + assertThat(fixture.scheduler.active()).isEmpty(); + } + } + + private static final class Fixture implements AutoCloseable { + private final TestClock clock = new TestClock(); + private final Timer scheduler = new Timer(clock); + private final PendingRecordCodec codec = new PendingRecordCodec(); + private final MemoryPendingMessageStore original = new MemoryPendingMessageStore<>(100, + value -> PendingMessageData.capture(value).restore(), value -> Instant.MIN, clock); + private final Map records = new HashMap<>(); + private final Map> groups = new HashMap<>(); + private final Map> bindings = new HashMap<>(); + private final Map, StandardMessage> projections = new LinkedHashMap<>(); + private final PendingMessageStore port; + private PendingMultipartRecovery recovery; + private int pages; + private int maximumPage; + private int payloadReads; + private int committedPublications; + private boolean sealed; + + @SuppressWarnings("unchecked") + private Fixture() { + original.open(); + port = mock(PendingMessageStore.class); + when(port.sourcesAfter(any(), anyInt())).thenAnswer(call -> { + pages++; + SourceId after = call.getArgument(0); + int limit = call.getArgument(1); + var page = records.keySet().stream().sorted(Comparator.comparing(SourceId::value)) + .filter(id -> after == null || id.value().compareTo(after.value()) > 0).limit(limit).toList(); + maximumPage = Math.max(maximumPage, page.size()); + return page; + }); + when(port.findMultipart(any())).thenAnswer(call -> Optional.ofNullable(read(call.getArgument(0)).multipart())); + when(port.find(any())).thenAnswer(call -> { + payloadReads++; + return Optional.of(read(call.getArgument(0)).message().restore()); + }); + when(port.multipartSources(any())).thenAnswer(call -> List.copyOf(groups.getOrDefault(call.getArgument(0), List.of()))); + doAnswer(call -> { + publish(call.getArgument(0), call.getArgument(1)); + return null; + }).when(port).makeHeldReady(any(), any()); + } + + private void publish(Set requested, StandardMessage message) { + Set sources = Set.copyOf(requested); + for (SourceId source : sources) { + if (bindings.containsKey(source) && !bindings.get(source).equals(sources)) { + throw new OutboundStorageException(OutboundStage.Role.PENDING, + OutboundStorageException.Reason.OWNERSHIP_CONFLICT, "Conflicting runtime publication"); + } + } + if (!projections.containsKey(sources)) { + committedPublications++; + projections.put(sources, PendingMessageData.capture(message).restore()); + sources.forEach(source -> bindings.put(source, sources)); + } + } + + private SourceId part(int ordinal, int total, String body) { + return part(ordinal, total, body, new SourceId(UUID.randomUUID())); + } + + private SourceId part(int ordinal, int total, String body, SourceId source) { + StandardMessage message = new StandardMessage(); + message.serial = source.value().toString(); + message.owner_id = "account"; + message.systemId = "system"; + message.from = "sender"; + message.to = "recipient"; + message.body = body; + message.binheader = "0500037F" + String.format("%02X%02X", total, ordinal); + original.admitPart(source, message, Duration.ofSeconds(60)); + return source; + } + + private void seal() { + if (sealed) { + return; + } + for (SourceId source : original.sourcesAfter(null, 100)) { + var membership = original.findMultipart(source).orElseThrow(); + records.put(source, codec.encode(new PendingSourceRecord(source, PendingSourceRecord.Disposition.HELD, + PendingMessageData.capture(original.find(source).orElseThrow()), membership))); + groups.computeIfAbsent(membership.groupId(), ignored -> new ArrayList<>()).add(source); + } + original.close(); // Only the copied codec records feed recovery, not a reopened memory store. + sealed = true; + } + + private PendingSourceRecord read(SourceId source) { + return codec.decode(records.get(source)); + } + + private void recover(int pageSize) { + seal(); + recovery = new PendingMultipartRecovery<>(port, clock, scheduler, port::makeHeldReady); + recovery.reconstruct(pageSize); + } + + @Override + public void close() { + if (recovery != null) { + recovery.close(); + } + scheduler.shutdownNow(); + original.close(); + } + } + + private static final class TestClock extends Clock { + private Instant now = Instant.parse("2026-10-20T12:00:00Z"); + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return Clock.fixed(now, zone); + } + + @Override + public Instant instant() { + return now; + } + } + + private static final class Timer extends ScheduledThreadPoolExecutor { + private final TestClock clock; + private final List tasks = new ArrayList<>(); + + private Timer(TestClock clock) { + super(1); + this.clock = clock; + } + + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + var task = new Task(command, clock.now.plusNanos(unit.toNanos(delay))); + tasks.add(task); + ScheduledFuture future = mock(ScheduledFuture.class); + when(future.cancel(false)).thenAnswer(call -> { + task.cancelled = true; + return true; + }); + return future; + } + + private List active() { + return tasks.stream().filter(task -> !task.cancelled).toList(); + } + + private void runDue() { + for (Task task : List.copyOf(active())) { + if (!task.due.isAfter(clock.now)) { + task.cancelled = true; + task.command.run(); + } + } + } + } + + private static final class Task { + private final Runnable command; + private final Instant due; + private boolean cancelled; + + private Task(Runnable command, Instant due) { + this.command = command; + this.due = due; + } + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodecTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodecTest.java new file mode 100644 index 00000000..ddb9cc30 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordCodecTest.java @@ -0,0 +1,214 @@ +package gr.cytech.sendium.core.storage.pending; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import gr.cytech.sendium.core.storage.OutboundStorageException; +import gr.cytech.sendium.core.storage.memory.MemoryPendingMessageStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.time.Duration; + +import static gr.cytech.sendium.core.storage.OutboundStage.Role.PENDING; +import static gr.cytech.sendium.core.storage.pending.PendingRecordMappingTest.message; +import static gr.cytech.sendium.core.storage.pending.PendingRecordMappingTest.source; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PendingRecordCodecTest { + private final PendingRecordCodec codec = new PendingRecordCodec(); + private final ObjectMapper cbor = new ObjectMapper(new CBORFactory()); + + @Test + void encodesSupportedExtensionsDeterministically() { + StandardMessage first = message(); + var values = new ArrayList(); + values.add(null); + values.addAll(List.of("text", true, (byte) 1, (short) 2, 3, 4L, 1.25f, 2.5d, new byte[]{0, (byte) 255})); + var nested = new LinkedHashMap(); + nested.put("z", values); + nested.put("a", "nested"); + first.attrs = new LinkedHashMap<>(); + first.attrs.put("b", 2L); + first.attrs.put("a", 1); + first.attrs.put("nested", nested); + StandardMessage second = message(); + second.attrs = new LinkedHashMap<>(); + second.attrs.put("nested", new LinkedHashMap<>(Map.of("a", "nested", "z", values))); + second.attrs.put("a", 1); + second.attrs.put("b", 2L); + // Match capture timestamps; defaults must not make otherwise equal records differ. + second.expr = first.expr; + second.ctstamp = first.ctstamp; + second.sutstamp = first.sutstamp; + + byte[] encoded = codec.encode(source(first)); + assertThat(encoded).isEqualTo(codec.encode(source(second))); + StandardMessage restored = codec.decode(encoded).message().restore(); + assertThat(restored.attrs.get("a")).isExactlyInstanceOf(Integer.class).isEqualTo(1); + assertThat(restored.attrs.get("b")).isExactlyInstanceOf(Long.class).isEqualTo(2L); + assertThat(restored.attrs).usingRecursiveComparison().isEqualTo(first.attrs); + } + + @Test + void rejectsUnknownVersion() throws Exception { + ObjectNode record = encodedTree(); + record.put("schemaVersion", 999); + assertFailure(cbor.writeValueAsBytes(record), "UNSUPPORTED_VERSION"); + } + + @Test + void rejectsMalformedRecords() throws Exception { + byte[] encoded = codec.encode(source(message())); + for (int length = 0; length < encoded.length; length++) { + assertFailure(Arrays.copyOf(encoded, length), "CORRUPT_RECORD"); + } + assertFailure(new byte[]{(byte) 255}, "CORRUPT_RECORD"); + assertFailure(cbor.writeValueAsBytes(List.of("not-an-envelope")), "CORRUPT_RECORD"); + for (String field : List.of("schemaVersion", "sourceId", "disposition", "message", "multipart")) { + ObjectNode record = encodedTree(); + record.remove(field); + assertFailure(cbor.writeValueAsBytes(record), "CORRUPT_RECORD"); + } + for (String field : List.of("priority", "body", "dcs", "dlrReturnMetadata")) { + ObjectNode record = encodedTree(); + ((ObjectNode) record.get("message")).remove(field); + assertFailure(cbor.writeValueAsBytes(record), "CORRUPT_RECORD"); + } + for (Object replacement : List.of(1.5, "wrong", true)) { + ObjectNode record = encodedTree(); + ((ObjectNode) record.get("message")).set("priority", cbor.valueToTree(replacement)); + assertFailure(cbor.writeValueAsBytes(record), "CORRUPT_RECORD"); + } + ObjectNode overflow = encodedTree(); + ((ObjectNode) overflow.get("message")).put("dcs", 256); + assertFailure(cbor.writeValueAsBytes(overflow), "CORRUPT_RECORD"); + ObjectNode mismatch = encodedTree(); + mismatch.put("sourceId", "6634ad89-7b7e-4b94-9328-3b982d3e0899"); + assertFailure(cbor.writeValueAsBytes(mismatch), "CORRUPT_RECORD"); + ObjectNode unknownDisposition = encodedTree(); + unknownDisposition.put("disposition", "invented"); + assertFailure(cbor.writeValueAsBytes(unknownDisposition), "CORRUPT_RECORD"); + byte[] trailing = Arrays.copyOf(encoded, encoded.length + 1); + trailing[trailing.length - 1] = 1; + assertFailure(trailing, "CORRUPT_RECORD"); + } + + @Test + void rejectsArbitraryClassReconstruction() throws Exception { + ObjectNode record = encodedTree(); + ObjectNode payload = (ObjectNode) record.get("message"); + ObjectNode malicious = cbor.createObjectNode(); + malicious.put("type", "java.lang.ProcessBuilder"); + malicious.put("value", "private-extension"); + payload.set("field1", malicious); + assertFailure(cbor.writeValueAsBytes(record), "CORRUPT_RECORD"); + } + + @Test + void redactsInvalidPayloadDiagnostics() throws Exception { + ObjectNode record = encodedTree(); + ((ObjectNode) record.get("message")).put("priority", "private-body-and-extension"); + try { + codec.decode(cbor.writeValueAsBytes(record)); + throw new AssertionError("Expected corrupt record"); + } catch (OutboundStorageException failure) { + assertThat(failure.stage()).isEqualTo(PENDING); + assertThat(((PendingRecordException) failure).kind()).isEqualTo(PendingRecordException.Kind.CORRUPT_RECORD); + assertThat(failure.getMessage()).doesNotContain("private", "body-and-extension"); + assertThat(failure.getCause()).isNull(); + } + byte[] invalidText = cbor.writeValueAsBytes("private-body-and-extension"); + assertThatThrownBy(() -> codec.decode(Arrays.copyOf(invalidText, invalidText.length - 1))) + .isInstanceOf(OutboundStorageException.class) + .hasNoCause() + .hasMessageNotContaining("private"); + } + + @Test + void rejectsUnsupportedExtensionValues() { + List cyclic = new ArrayList<>(); + cyclic.add(cyclic); + Map invalidKeys = new HashMap<>(); + invalidKeys.put(7, "private"); + for (Object value : List.of(new Object(), Thread.State.NEW, cyclic, invalidKeys, Float.NaN, Double.POSITIVE_INFINITY)) { + StandardMessage message = message(); + message.field1 = value; + assertThatThrownBy(() -> codec.encode(source(message))) + .isInstanceOfSatisfying(OutboundStorageException.class, + failure -> assertThat(failure.reason().name()).isEqualTo("UNSUPPORTED")) + .hasNoCause() + .hasMessageNotContaining("private"); + } + } + + @Test + void acceptsUnknownOptionalFieldsWithinKnownVersion() throws Exception { + ObjectNode record = encodedTree(); + record.put("futureOptionalNote", "ignored"); + ((ObjectNode) record.get("message")).put("futureOptionalInput", 17); + assertThat(codec.decode(cbor.writeValueAsBytes(record)).message().restore().body).isEqualTo("private body"); + } + + @ParameterizedTest + @ValueSource(strings = {"0500037F0301", "06080412340301", "0B050423F023F100037F0301"}) + void roundTripsMultipartMembership(String udh) throws Exception { + try (var pending = new MemoryPendingMessageStore(2, value -> PendingMessageData.capture(value).restore())) { + pending.open(); + StandardMessage first = message(); + first.binheader = udh; + SourceId firstId = new SourceId(UUID.fromString(first.serial)); + var original = pending.admitPart(firstId, first, Duration.ofSeconds(60)); + StandardMessage repeated = message(); + repeated.serial = UUID.randomUUID().toString(); + repeated.binheader = first.binheader; + SourceId repeatedId = new SourceId(UUID.fromString(repeated.serial)); + var separate = pending.admitPart(repeatedId, repeated, Duration.ofSeconds(60)); + var record = new PendingSourceRecord(repeatedId, PendingSourceRecord.Disposition.HELD, + PendingMessageData.capture(pending.find(repeatedId).orElseThrow()), separate); + var decoded = codec.decode(codec.encode(record)); + assertThat(decoded.multipart()).isEqualTo(separate); + assertThat(decoded.multipart().groupId()).isEqualTo(original.groupId()); + assertThat(decoded.multipart().originalSource()).isEqualTo(firstId); + assertThat(decoded.multipart().duplicate()).isTrue(); + assertThat(decoded.multipart().source()).isEqualTo(repeatedId); + assertThat(decoded.message().restore().serial).isEqualTo(repeated.serial); + + ObjectNode invalid = (ObjectNode) cbor.readTree(codec.encode(record)); + ((ObjectNode) invalid.get("multipart")).put("sourceId", first.serial); + assertFailure(cbor.writeValueAsBytes(invalid), "CORRUPT_RECORD"); + invalid = (ObjectNode) cbor.readTree(codec.encode(record)); + ((ObjectNode) invalid.get("multipart").get("key")).put("recipient", "different recipient"); + assertFailure(cbor.writeValueAsBytes(invalid), "CORRUPT_RECORD"); + invalid = (ObjectNode) cbor.readTree(codec.encode(record)); + ((ObjectNode) invalid.get("multipart")).put("ordinal", 4); + assertFailure(cbor.writeValueAsBytes(invalid), "CORRUPT_RECORD"); + invalid = (ObjectNode) cbor.readTree(codec.encode(record)); + ((ObjectNode) invalid.get("multipart")).put("deadline", "invalid private deadline"); + assertFailure(cbor.writeValueAsBytes(invalid), "CORRUPT_RECORD"); + } + } + + private ObjectNode encodedTree() throws Exception { + return (ObjectNode) cbor.readTree(codec.encode(source(message()))); + } + + private void assertFailure(byte[] input, String reason) { + assertThatThrownBy(() -> codec.decode(input)).isInstanceOfSatisfying(OutboundStorageException.class, failure -> { + assertThat(failure.stage()).isEqualTo(PENDING); + assertThat(((PendingRecordException) failure).kind().name()).isEqualTo(reason); + assertThat(failure.getCause()).isNull(); + }); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordMappingTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordMappingTest.java new file mode 100644 index 00000000..6637363b --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/storage/pending/PendingRecordMappingTest.java @@ -0,0 +1,139 @@ +package gr.cytech.sendium.core.storage.pending; + +import gr.cytech.sendium.core.message.DlrReturnMetadata; +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.outbound.OutboundWork.SourceId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PendingRecordMappingTest { + private final PendingRecordCodec codec = new PendingRecordCodec(); + + static Stream messageFields() { + return Stream.of(StandardMessage.class.getFields()).filter(field -> !Modifier.isStatic(field.getModifiers())); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("messageFields") + void roundTripsEveryPreservedField(Field field) throws Exception { + StandardMessage message = message(); + Object sample = sample(field); + field.set(message, sample); + var source = source(message); + + var recovered = codec.decode(codec.encode(source)); + + assertThat(recovered.source()).isEqualTo(source.source()); + assertThat(recovered.disposition()).isEqualTo(source.disposition()); + assertThat(field.get(recovered.message().restore())).usingRecursiveComparison().isEqualTo(sample); + } + + @Test + void sourceIdentityMustMatchGatewayIdentity() { + assertThatThrownBy(() -> new PendingSourceRecord(new SourceId(UUID.randomUUID()), + PendingSourceRecord.Disposition.READY, PendingMessageData.capture(message()))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void capturedDataIsDetachedFromMutableInputsAndRestoredMessages() { + StandardMessage message = message(); + byte[] bytes = {1, 2}; + var list = new ArrayList(List.of("first", bytes)); + message.attrs = new HashMap<>(Map.of("items", list)); + message.tlvs = new HashMap<>(Map.of("0427", "1")); + message.reassembledParts = new ArrayList<>(List.of("source-a")); + PendingMessageData data = PendingMessageData.capture(message); + bytes[0] = 9; + list.clear(); + message.tlvs.clear(); + message.reassembledParts.clear(); + + StandardMessage restored = data.restore(); + assertThat((List) restored.attrs.get("items")).hasSize(2); + assertThat((byte[]) ((List) restored.attrs.get("items")).get(1)).containsExactly(1, 2); + restored.attrs.clear(); + restored.tlvs.clear(); + restored.reassembledParts.clear(); + assertThat(data.restore().attrs).containsKey("items"); + assertThat(data.restore().tlvs).containsEntry("0427", "1"); + assertThat(data.restore().reassembledParts).containsExactly("source-a"); + } + + @Test + void preservesNullCollectionsAndBothDlrChannels() { + for (var metadata : List.of(DlrReturnMetadata.http("account", "sender", "recipient", "https://example.test/dlr"), + DlrReturnMetadata.smpp("account", "system", "sender", "recipient"))) { + StandardMessage message = message(); + message.dlrReturnMetadata = metadata; + StandardMessage restored = codec.decode(codec.encode(source(message))).message().restore(); + assertThat(restored.dlrReturnMetadata).isEqualTo(metadata); + assertThat(restored.attrs).isNull(); + assertThat(restored.tlvs).isNull(); + assertThat(restored.reassembledParts).isNull(); + } + } + + static StandardMessage message() { + StandardMessage message = new StandardMessage(); + message.serial = "6634ad89-7b7e-4b94-9328-3b982d3e0898"; + message.from = "sender"; + message.to = "recipient"; + message.body = "private body"; + return message; + } + + static PendingSourceRecord source(StandardMessage message) { + return new PendingSourceRecord(new SourceId(UUID.fromString(message.serial)), + PendingSourceRecord.Disposition.READY, PendingMessageData.capture(message)); + } + + private static Object sample(Field field) { + return switch (field.getName()) { + case "serial" -> message().serial; + case "attrs" -> new HashMap<>(Map.of("routing-input", List.of("value", 7L))); + case "tlvs" -> new HashMap<>(Map.of("0427", "1")); + case "reassembledParts" -> new ArrayList<>(List.of("original-a", "original-b")); + case "dlrReturnMetadata" -> DlrReturnMetadata.http("account", "sender", "recipient", "https://example.test/dlr"); + case "marginStatus" -> StandardMessage.MarginStatus.KNOWN; + default -> { + Class type = field.getType(); + if (type == String.class) { + yield "value-for-" + field.getName(); + } + if (type == int.class) { + yield 37; + } + if (type == long.class) { + yield 9_000_000_001L; + } + if (type == byte.class) { + yield (byte) -7; + } + if (type == boolean.class) { + yield true; + } + if (type == double.class) { + yield 1.25d; + } + if (type == Object.class) { + yield new ArrayList<>(List.of("extension", (short) 4, new byte[]{0, (byte) 255})); + } + throw new AssertionError("Unmapped field: " + field.getName()); + } + }; + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java b/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java index d1545ee7..e162ca97 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java @@ -3,6 +3,8 @@ import gr.cytech.sendium.core.message.StandardMessage; import org.junit.jupiter.api.Test; +import java.util.HexFormat; + import static org.assertj.core.api.Assertions.assertThat; class MessageUtilTest { @@ -43,6 +45,66 @@ void udhHelpersReturnDefaultsWhenNoSupportedUdhExists() { assertThat(MessageUtil.getNumberOfCurrentPart(message)).isZero(); } + @Test + void concatenationMayAppearBesideOtherInformationElements() { + var first = messageWithUdh("0b050423f023f100037f0301"); + var second = messageWithUdh("0B050423F023F100037F0302"); + var parsed = MessageUtil.concatenation(first).orElseThrow(); + assertThat(parsed.referenceBits()).isEqualTo(8); + assertThat(parsed.reference()).isEqualTo(127); + assertThat(parsed.total()).isEqualTo(3); + assertThat(parsed.ordinal()).isEqualTo(1); + assertThat(parsed.normalizedUdh()).isEqualTo(MessageUtil.concatenation(second).orElseThrow().normalizedUdh()); + assertThat(MessageUtil.getNumberOfCurrentPart(first)).isEqualTo(1); + assertThat(MessageUtil.getNumberOfTotalParts(first)).isEqualTo(3); + var trailingPort = messageWithUdh("0B00037F0301050423F023F1"); + assertThat(MessageUtil.getNumberOfCurrentPart(trailingPort)).isEqualTo(1); + } + + @Test + void invalidConcatenationCannotBeAdmittedAsASupportedPart() { + for (String udh : java.util.List.of("050003", "0500037F0001", "0500037F0200", "0500037F0203", "0500037F020Z", + "0400037F0201", "0500047F0201", "0A00037F020100037F0201", "06080312340201")) { + var message = messageWithUdh(udh); + assertThat(MessageUtil.concatenation(message)).as(udh).isEmpty(); + assertThat(MessageUtil.is8BitMessagePart(message)).as(udh).isFalse(); + assertThat(MessageUtil.is16BitMessagePart(message)).as(udh).isFalse(); + } + } + + @Test + void zeroLengthAndTruncatedConcatenationElementsAreRejectedSafely() { + for (int identifier : new int[]{0, 8}) { + int requiredLength = identifier == 0 ? 3 : 4; + for (int declared = 0; declared <= requiredLength; declared++) { + for (int available = 0; available < requiredLength; available++) { + byte[] header = new byte[3 + available]; + header[0] = (byte) (header.length - 1); + header[1] = (byte) identifier; + header[2] = (byte) declared; + java.util.Arrays.fill(header, 3, header.length, (byte) 1); + String udh = HexFormat.of().withUpperCase().formatHex(header); + StandardMessage message = messageWithUdh(udh); + assertThat(MessageUtil.concatenation(message)).as(udh).isEmpty(); + assertThat(MessageUtil.is8BitMessagePart(message)).as(udh).isFalse(); + assertThat(MessageUtil.is16BitMessagePart(message)).as(udh).isFalse(); + assertThat(MessageUtil.getNumberOfCurrentPart(message)).as(udh).isZero(); + assertThat(MessageUtil.getNumberOfTotalParts(message)).as(udh).isZero(); + } + } + } + } + + @Test + void maximumWidthReferencesRemainSupported() { + var eightBit = MessageUtil.concatenation(messageWithUdh("050003FF0201")).orElseThrow(); + var sixteenBit = MessageUtil.concatenation(messageWithUdh("060804FFFF0201")).orElseThrow(); + assertThat(eightBit.referenceBits()).isEqualTo(8); + assertThat(eightBit.reference()).isEqualTo(255); + assertThat(sixteenBit.referenceBits()).isEqualTo(16); + assertThat(sixteenBit.reference()).isEqualTo(65535); + } + @Test void getSmsCntUsesTextAndConcatenationBoundaries() { assertThat(MessageUtil.getSmsCnt("a".repeat(160), StandardMessage.MSG_TEXT, true)).isEqualTo(1); diff --git a/sendium-core/src/test/java/utils/OutboundIngressFixture.java b/sendium-core/src/test/java/utils/OutboundIngressFixture.java index 3cdacc9e..bec21c06 100644 --- a/sendium-core/src/test/java/utils/OutboundIngressFixture.java +++ b/sendium-core/src/test/java/utils/OutboundIngressFixture.java @@ -18,7 +18,7 @@ public final class OutboundIngressFixture implements AutoCloseable { public final DefaultOutboundCoordinator coordinator; public OutboundIngressFixture(int capacity) { - pending = new MemoryPendingMessageStore<>(capacity, OutboundIngressFixture::snapshot); + pending = spy(new MemoryPendingMessageStore<>(capacity, OutboundIngressFixture::snapshot)); router = new MemorySelectedRouterStore<>(pending, capacity); routed = new MemoryRoutedWorkStore<>(capacity, OutboundIngressFixture::snapshot); coordinator = spy(new DefaultOutboundCoordinator<>(pending, router, routed));