fix: resolve producer lookup failure when posting Reply messages#4070
fix: resolve producer lookup failure when posting Reply messages#4070
Conversation
Code Review: Fix/reply producer lookupOverviewThis PR fixes a real bug: when a reply message mapper overrides The approach is pragmatic and well-targeted. The test coverage is thorough. A few issues worth discussing before merge: Issues / Risks1.
|
- deterministic poll-until in bulk dispatch test (drop Task.Delay) - document firstMessage invariant in BulkDispatchAsync - clarify ProducerTopicHeaderName xml doc (bag key, not header) - braces around single-line if in WrapPipeline + WrapPipelineAsync - inline note on Log.DecoupledInvocationOfMessage divergence from lookup Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
Code ReviewThe fix is well-motivated and the production code change is minimal. Here are my findings, grouped by severity.
|
Code ReviewThis is a well-reasoned fix for a real bug. The design is sound — using Issues worth addressing before merge1. The comment in the test correctly explains the assumption, but the production code comment in
This is only true if all messages in the batch share the same publication (i.e., same producer topic in the bag). If a future path ever groups messages from different publications under the same 2. Outbox retains the bag entry after dispatch
3. Polling loop in while (_internalBus.Stream(_replyTopic).Count() < 2 && DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(25);
}
Assert.Equal(2, messages.Length); // timed out after 5s — bulk dispatch may not have firedMinor observations4. Comment style in production code The inline comments added to 5. The constant lives on 6. Null-topic fallback in When SummaryThe core fix is correct and the test suite covers the important paths (sync post, async post, bulk dispatch, null-topic guard). The main thing I'd want resolved before merge is item #1 (making the Marking as DRAFT makes sense until CI is green — let us know when you're ready for a final pass. |
PR Review: fix: resolve producer lookup failure when posting Reply messagesThe fix is conceptually sound and well-scoped. Using a transient Bug:
|
| Area | Finding |
|---|---|
| Core logic | ✅ Correct and minimal |
| Null-topic guard | ✅ Present and tested |
| Test coverage | ✅ Comprehensive (sync / async / bulk / null-guard) |
| InMemoryOutbox mutation | |
| BulkDispatch firstMessage assumption | ℹ️ Correct but undocumented safety net |
| Comment style | ℹ️ Some what-comments contra CLAUDE.md |
| Test polling | ℹ️ Could be flaky under CI load |
| Missing sample .csproj files | ℹ️ Verify or exclude from slnx |
Review: fix/resolve-producer-lookup-failure-reply-messagesThe fix is conceptually sound and well-tested. Previous reviews have progressively tightened it; the remaining issue worth resolving before merge is the 1.
|
Code Review — PR #4070: Resolve producer lookup failure for Reply messagesOverall: This is a clean, well-targeted bug fix with good test coverage. The approach of stashing the publication topic in What the PR doesWhen a Reply mapper sets Issues and suggestions1. Debug.Assert(
topicBatch.All(m => GetProducerLookupTopic(m) == producerLookupTopic), ...);
topicBatch.All(m => GetProducerLookupTopic(m).Value == producerLookupTopic.Value)2. In var producer = _producerRegistry.LookupBy(GetProducerLookupTopic(message), ...);
StripProducerLookupTopic(message); // ← strip before sendIf // Strip before send: the bag entry is only needed for producer lookup above;
// Header.Topic (the wire destination) is unchanged.
StripProducerLookupTopic(message);3. Missing outbox assertion in The test verifies the dispatched wire message is stripped, and that the outbox record exists — but it doesn't assert that the outbox record was stored with the // bag entry present in outbox (enables future drain to find the producer)
Assert.True(outboxMessage.Header.Bag.ContainsKey(Message.ProducerTopicHeaderName));Note: this depends on whether 4. The constant is 5. var firstMessage = topicBatch.First();
var producerLookupTopic = GetProducerLookupTopic(firstMessage);
Debug.Assert(topicBatch.All(m => GetProducerLookupTopic(m) == producerLookupTopic), ...);If What's done well
SummaryThe logic is sound and the tests cover the important paths. The main actionable item is #1 (verify |
There was a problem hiding this comment.
Pull request overview
Fixes outbox dispatch producer lookup for Reply messages when a mapper overrides Message.Header.Topic (e.g., to a dynamic reply address) by stashing the publication topic into Message.Header.Bag for lookup, then stripping it prior to transport dispatch to avoid leaking internal metadata.
Changes:
- Add
Message.ProducerTopicHeaderNamebag-key constant for preserving the publication topic across outbox persistence/dispatch. - Update
WrapPipeline.WrapandWrapPipelineAsync.WrapAsyncto write the publication topic intoHeader.Bagwhen mapper/publication topics differ (guarded fornullpublication topic). - Update
OutboxProducerMediatordispatch paths (sync/async/bulk) to resolve producer lookup topic from the bag and strip the bag entry before dispatch; add targeted tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Paramore.Brighter/Message.cs | Adds ProducerTopicHeaderName constant for an internal bag entry used during producer lookup. |
| src/Paramore.Brighter/WrapPipeline.cs | Writes publication topic into Header.Bag when mapper overrides the message topic. |
| src/Paramore.Brighter/WrapPipelineAsync.cs | Async equivalent of the wrap change to stash publication topic into Header.Bag. |
| src/Paramore.Brighter/OutboxProducerMediator.cs | Uses the bag-stashed publication topic for producer lookup and strips it before dispatch (sync/async/bulk). |
| tests/Paramore.Brighter.Core.Tests/MessageSerialisation/When_Wrapping_A_Reply_Message_Mapper*.cs | Verifies bag population when wrapping Reply mappers (sync/async). |
| tests/Paramore.Brighter.Core.Tests/MessageSerialisation/When_Wrapping_With_Null_Publication_Topic*.cs | Pins the null-publication-topic guard (sync/async). |
| tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_A_Reply_Message_To_The_Command_Processor*.cs | End-to-end post verifying dispatch and that the bag entry is stripped before sending. |
| tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Bulk_Dispatching_Reply_Messages_Async.cs | Covers bulk outbox clear path and asserts bag stripping across the batch. |
| tests/Paramore.Brighter.Core.Tests/CommandProcessors/TestDoubles/MyResponseMessageMapperAsync.cs | Async mapper used by the new end-to-end/bulk tests to simulate Reply behavior. |
Debug.Assert is stripped in Release, so it could not enforce the single- producer invariant at runtime. Group by the composite key instead — this guarantees every batch resolves to one producer without any assertion. Addresses Copilot review feedback on PR #4070. Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
Code Review — PR #4070: Resolve producer lookup failure for Reply messagesOverall: The design is sound — using Previous reviews have already covered the 1. Stale comment in the bulk-dispatch test (Low — clarity)
// Gap: BulkDispatchAsync groups by Header.Topic (the mapper-set reply topic) but
// looks up the producer using the first message's bag … so a future refactor of
// GetProducerLookupTopic or the firstMessage assumption cannot silently regress …Neither claim matches the current implementation. The 2.
|
|
Thanks for the fresh review — both findings addressed in a7d761d:
On the InMemoryOutbox mutation hazard from earlier reviews — added a doc comment on |
Code ReviewOverviewThis PR fixes a real bug in the request-reply pattern: when a The approach is minimal and targeted. The fallback to Potential Issue: Span dictionary key collisionIn producerSpans.TryAdd($"{topicBatch.Key.WireTopic}|{topicBatch.Key.LookupTopic}", span);Using // Declare the dict as:
var producerSpans = new ConcurrentDictionary<(RoutingKey WireTopic, RoutingKey LookupTopic), Activity?>();
producerSpans.TryAdd(topicBatch.Key, span);This is a minor concern but worth addressing since span cleanup relies on this key matching the corresponding Design note: strip-before-retry hazard with InMemoryOutboxThe comment on
Agreed that this is acceptable for BulkDispatchAsync grouping is correctThe new grouping key Strip ordering is correctIn both var producer = _producerRegistry.LookupBy(GetProducerLookupTopic(message), ...);
StripProducerLookupTopic(message);If Test coverageTests are comprehensive:
One gap: there is no test confirming that the Rolling upgrade concern (documented, correct)The PR description correctly identifies the rolling upgrade hazard: messages deposited by old instances won't carry the Summary
The core fix is solid. The two flagged items are minor — the span key collision is the only one worth changing before merge. |
|
CI build job has failed twice on the same pre-existing flaky test — unrelated to this PR: The test uses Could a maintainer re-run the |
63bda5a to
53996d4
Compare
Code ReviewOverviewThis PR fixes a real and well-understood bug: reply message mappers set Strengths
Issues / Suggestions1.
|
Code ReviewSeveral prior reviews have covered the core design thoroughly — this pass focuses on what remains unresolved and one new observation. Previous feedback addressed (good)
Still unresolved:
|
Code Review — PR #4070: fix: resolve producer lookup failure when posting Reply messagesOverviewA well-targeted fix for a real production failure: reply message mappers override The design decision to preserve the bag entry on the original message reference (rather than stripping it in the mediator) is the right call — it means Strengths
Issues1.
|
When posting a Reply, the message mapper sets the header topic to the dynamic reply address (a GUID queue name). The outbox dispatcher then fails to find the producer because it's registered under the static publication topic (e.g. "Reply"), not the GUID. Store the publication topic in the message header Bag during the wrap pipeline when a topic mismatch is detected, then use it as the lookup key in all three dispatch methods (sync, async, bulk). Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
… lookup - bulk dispatch reply messages via outbox sweep (firstMessage bag lookup) - wrap with null publication.Topic skips bag write (guard pin, sync + async) Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
- deterministic poll-until in bulk dispatch test (drop Task.Delay) - document firstMessage invariant in BulkDispatchAsync - clarify ProducerTopicHeaderName xml doc (bag key, not header) - braces around single-line if in WrapPipeline + WrapPipelineAsync - inline note on Log.DecoupledInvocationOfMessage divergence from lookup Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
- strip ProducerTopicHeaderName from Header.Bag after lookup in Dispatch, DispatchAsync, and BulkDispatchAsync so transports that serialise the bag (AMQP headers, SNS/SQS attributes) don't leak internal topology - namespace bag key as "paramore.brighter.ProducerTopic" to eliminate collision risk with user-defined bag entries - rename async wrap-reply test to end with _Async per suite convention - extend reply post + bulk dispatch tests to pin the strip behaviour Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
…plexity flat Moves the per-message strip loop into an IEnumerable<Message> overload of StripProducerLookupTopic so the added foreach no longer counts against BulkDispatchAsync's cyclomatic complexity (CodeScene delta warning). Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
…, test cleanup - Debug.Assert in BulkDispatchAsync that every message in a topicBatch resolves to the same producer-lookup topic (makes the firstMessage assumption explicit and cheaply catchable) - drop the poll loop in the bulk dispatch test; ClearOutstandingFromOutboxAsync awaits the dispatch so it completes before return - shorten StripProducerLookupTopic xml doc to a one-line comment - add a brief note in GetProducerLookupTopic explaining the null-topic fallback Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
The slnx was accidentally swept into an earlier commit with references to samples/TaskQueue/ASBRequestReply/ projects that were never committed to the branch, breaking CI build. Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
Debug.Assert is stripped in Release, so it could not enforce the single- producer invariant at runtime. Group by the composite key instead — this guarantees every batch resolves to one producer without any assertion. Addresses Copilot review feedback on PR #4070. Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
…on, outbox note - rewrite the stale class-level comment in When_Bulk_Dispatching_Reply_Messages_Async to match the current composite-key grouping - disambiguate producerSpans key with the composite (WireTopic, LookupTopic) so two batches sharing a WireTopic but differing in LookupTopic can both end their spans cleanly - document on StripProducerLookupTopic that the mutation is harmless for DB-backed outboxes but affects InMemoryOutbox retries (primarily dev/test scope) Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
…e span key Addresses copilot review feedback on #4070: - StripProducerLookupTopic now returns the prior bag value so each dispatch path restores it in a finally block when dispatch did not succeed. InMemoryOutbox stores the Message by reference, so without this a post-strip send failure would leave the outbox entry missing ProducerTopic and retry would fall back to Header.Topic. - BulkDispatchAsync's producerSpans key is now Guid.NewGuid().ToString() rather than "{WireTopic}|{LookupTopic}" — routing keys may legally contain '|', which could collide and drop spans. Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
… paths Per review feedback: the original comment focused on the fallback being "pre-fix behaviour," which obscured *why* the bag entry exists. Rewrite to call out the Reply path (mapper rewrites Header.Topic to a dynamic reply address) and the normal-publication path (no bag entry, falls back to Header.Topic). Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
Per review feedback (iancooper): the OutboxProducerMediator was stripping the ProducerTopic bag entry around dispatch and restoring it on failure. That mutated the message reference held by InMemoryOutbox, so on retry the producer hint was gone. Move stripping responsibility to the transport's wire-conversion step: - MessageHeader.LocalHeaderNames: static set of bag keys that are internal to Brighter and must not be serialised onto the wire. Pre-populated with Message.ProducerTopicHeaderName; extensible from downstream code. - MessageHeader.StripLocalHeaders(): instance method removing those keys from the bag (provided as a transport convenience). - AzureServiceBusMessagePublisher: skip LocalHeaderNames when copying Header.Bag into ApplicationProperties. - OutboxProducerMediator: drop StripProducerLookupTopic / RestoreProducerLookupTopic and the dispatched/batchDispatched book- keeping that existed only to drive the restore. The bag entry now survives a successful dispatch — InMemoryOutbox-by-reference keeps the producer hint for retries. Tests updated: reply-message Post tests now assert the bag entry survives dispatch (it's the transport's job to omit it on the wire). New unit tests cover MessageHeader.StripLocalHeaders and the ASB publisher's local-header skip. Note: only the ASB transport has been migrated. Other transports that serialise Header.Bag (RMQ, SNS/SQS, Kafka, …) will now leak the paramore.brighter.ProducerTopic entry on the wire — benign for receivers but a behaviour change. Follow-up work to apply the same pattern to those transports is documented on LocalHeaderNames. Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
ASB stripped the ProducerTopic local header on the wire already (prior commit). Apply the same pattern to every other transport that copies Header.Bag onto its wire format so paramore.brighter.ProducerTopic doesn't leak. Inline-filter transports (existing skip-set extended with LocalHeaderNames): - RMQ.Async / RMQ.Sync RmqMessagePublisher - Kafka KafkaDefaultMessageHeaderBuilder - GcpPubSub Parser - RocketMQ RocketMqMessageProducer - MessageScheduler.Azure AzureServiceBusScheduler Whole-bag-as-JSON transports (use new MessageHeader.BagWithoutLocalHeaders): - AWSSQS V3 + V4 SnsMessagePublisher - AWSSQS V3 + V4 SqsMessageSender - Redis RedisMessagePublisher MessageHeader gains BagWithoutLocalHeaders() — returns a new dictionary copy minus LocalHeaderNames — so transports that serialise the bag in one shot (SNS / SQS / Redis emit it as a single JSON property) can hand the filtered view to the serialiser without mutating the original header (preserves InMemoryOutbox-by-reference retries). Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
…alHeader The first cut exposed LocalHeaderNames as a public mutable HashSet so transports could call .Contains directly. That let any caller remove the framework-essential ProducerTopicHeaderName entry, and read/write races were possible if a registration ran while a publisher was iterating. Hide the storage and expose two operations: - IsLocalHeader(name): lock-free read against a copy-on-write snapshot (Volatile.Read of the field). This is on the per-bag-entry hot path. - RegisterLocalHeader(name): CAS loop that swaps in a new HashSet containing the additional key. Idempotent. Expected to be called once at startup from extension code. The field itself becomes private; the snapshot is never mutated in place after publication, so readers can iterate it freely without locking. StripLocalHeaders and BagWithoutLocalHeaders snapshot once via Volatile.Read at the start. ImmutableHashSet was the natural fit but isn't in the netstandard2.0 BCL and Brighter doesn't reference the package — emulating copy-on-write with HashSet keeps the same semantics without adding a dependency. Call sites in the 9 transports + the local-header tests switch to MessageHeader.IsLocalHeader. New test covers RegisterLocalHeader idempotency and that custom keys are honoured by BagWithoutLocalHeaders. Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
No transport calls it — they all use IsLocalHeader for inline filtering or BagWithoutLocalHeaders for whole-bag JSON serialisation. Keeping the method around invites callers to mutate the message's bag in place, which would re-introduce the InMemoryOutbox-by-reference regression that moving stripping out of the mediator just fixed. Test class renamed MessageHeaderStripLocalHeadersTests → MessageHeaderLocalHeadersTests and the two strip-specific cases rewritten against BagWithoutLocalHeaders (also asserts the original header is untouched, pinning the InMemoryOutbox-by-reference invariant). Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
…r contract, pin fallback
Three issues from claude[bot]'s latest review:
1. The new ASB unit test carried [Trait("Fragile", "CI")], copied by
mistake from the live-ASB integration tests. The CI pipeline uses
"Fragile!=CI" as its filter, so the trait skipped the only
transport-level pin for local-header filtering. Drop it.
2. GetProducerLookupTopic uses `producerTopic is string topic`. Concern
was that persistent outboxes round-trip Header.Bag through JSON, so
values would come back as JsonElement and the cast would silently
fail — reproducing the bug the PR fixes.
Investigation shows Brighter's bag round-trip uses
JsonSerialisationOptions.Options, which composes
DictionaryStringObjectJsonConverter with ObjectToInferredTypesConverter
to preserve string runtime types. Verified RelationDatabaseOutbox
(covers MsSql, PostgreSQL, MySql, Sqlite, Spanner), MongoDb, and
DynamoDB all use those options on deserialise.
Document the contract on GetProducerLookupTopic and add
BagStringValueRoundTripTests to pin the converter behaviour. If a
future change drops one of the converters, the round-trip test fails
loudly instead of GetProducerLookupTopic regressing silently.
3. Add WrapMatchingPublicationTopicTests to pin the fallback path: when
a mapper does NOT override Header.Topic, no ProducerTopic bag entry
is written and producer lookup falls back to Header.Topic. The
existing test trio (reply-mapper-overrides, null-publication-topic,
matching-topic) now covers all three branches of WrapPipeline.Wrap.
Co-Authored-By: Claude (claude-opus-4-7) <noreply@anthropic.com>
a8ccf2c to
b4406b9
Compare
There was a problem hiding this comment.
Gates Failed
Enforce advisory code health rules
(1 file with Complex Method, Complex Conditional)
Gates Passed
3 Quality Gates Passed
See analysis details in CodeScene
Reason for failure
| Enforce advisory code health rules | Violations | Code Health Impact | |
|---|---|---|---|
| RocketMqMessageProducer.cs | 2 advisory rules | 8.19 → 7.93 | Suppress |
Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.
Code ReviewOverviewThis PR fixes a real bug: when a reply message mapper sets Correctness
The write is correctly guarded for
The fallback chain (bag entry →
Changing the group key from Potential Issues1.
2. For SNS/SQS/Redis this is called once per message send, which is likely acceptable. However, if the Bag is empty or contains only local headers, the allocation is wasted. A minor optimisation would be to return 3. The method mutates static state that persists for the life of the process (including across tests in the same process). The test key in 4. Deployment / rolling upgrade gap is only in the PR description The note that old outbox rows persisted without Code StyleThe comment density in // Log the wire topic (Header.Topic) — where the message is going.This sentence restates what Similarly, the inline Test CoverageCoverage is thorough and well structured:
One gap: there is no test covering the mixed bulk dispatch scenario — a batch containing both a reply message (with SummaryThe fix is correct and the design decisions are well-reasoned. The main actionable items before merge:
Overall this is a well-scoped, well-tested fix. LGTM pending the visibility verification. |
iancooper
left a comment
There was a problem hiding this comment.
This looks much better, thanks for taking the time with this.
|
Than you for the review! This will massively help us. |
Summary
When a message mapper sets
Message.Header.Topicto a value different from the registered publication topic (as Reply mappers do, using the reply address), the outbox dispatcher was looking up the producer by the mapper-set topic and failing to find it.Fix: the wrap pipeline stashes the publication topic in
Header.Bag[ProducerTopicHeaderName]when it differs from the mapper-set topic. The outbox dispatcher reads that bag entry for producer lookup (falling back toHeader.Topicwhen absent). Stripping the bag entry on the wire is the transport's responsibility — the message itself keeps the entry soan
InMemoryOutbox-by-reference retry still locates the producer.Changes
Core
Message.ProducerTopicHeaderNameconstant (namespaced asparamore.brighter.ProducerTopic)WrapPipeline.Wrap/WrapPipelineAsync.WrapAsync: writepublication.Topicinto bag when topics differ (guarded for null publication topic)OutboxProducerMediator.GetProducerLookupTopic: new private helper used byDispatch,DispatchAsync,BulkDispatchAsync. The mediator no longer mutates the bag — no strip-and-restore dance, nodispatchedbook-keeping that existed only to drive the restoreMessageHeader.IsLocalHeader(name)/MessageHeader.RegisterLocalHeader(name): public extension seam for marking bag keys as local (must not be serialised onto the wire). Pre-populated withProducerTopicHeaderName. Backed by a copy-on-writeHashSet<string>snapshot — lock-free reads on the per-bag-entry hot path, CAS-loop registrations expected atstartup. No new package dependency.
MessageHeader.BagWithoutLocalHeaders(): returns a filtered copy of the bag for transports that JSON-serialise the whole bag in one goTransport sweep
Every transport that copies
Header.Bagonto its wire format now skips local headers viaMessageHeader.IsLocalHeader(...):BagWithoutLocalHeaders()because they JSON-serialise the whole bag in one go)BagWithoutLocalHeaders())Followed iancooper's review preference: stripping happens at the wire-conversion step in each transport, not centrally in the mediator. This preserves the bag entry on the original message reference held by
InMemoryOutbox, so retries still resolve the correct producer.Deployment note — rolling upgrade
During a rolling upgrade, outbox rows persisted by an old instance won't have the
ProducerTopicbag entry. When a new instance drains them, producer lookup falls back toHeader.Topic(the reply address) and will fail for reply messages. Either drain the outbox before upgrading, or accept that stuck reply rows may need to be re-posted after the rolloutcompletes.
Tests
When_Posting_A_Reply_Message_To_The_Command_Processor(+ async) — end-to-end post + asserts bag entry survives dispatch on the original message (transports do the wire-form stripping)When_Wrapping_A_Reply_Message_Mapper(+ async) — verifies bag is populated with publication topicWhen_Bulk_Dispatching_Reply_Messages_Async— pinsBulkDispatchAsyncpathWhen_Wrapping_With_Null_Publication_Topic(+ async) — pins the null-publication-topic guardMessageHeaderLocalHeadersTests—BagWithoutLocalHeaders+IsLocalHeader+RegisterLocalHeaderidempotency, asserts the original header is untouchedAzureServiceBusMessagePublisherLocalHeaderTests— confirms ASB wire form dropsProducerTopicwhile the original message keeps it, and unrelated bag entries still travelTest plan
Co-Authored-By: Claude (claude-opus-4-7) noreply@anthropic.com