Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
* BigQueryIO now supports reading BigQuery Lakehouse runtime catalog (BigLake metastore) Iceberg tables with the Storage Read API, using 4-part `project.catalog.namespace.table` identifiers (or a `TableReference` with a composite `catalog.namespace` dataset id). Previously such references were silently mis-parsed (Java) ([#39597](https://github.com/apache/beam/issues/39597)) .
* SolaceIO now supports reading and writing binary and text content data payload (Java) ([#39875](https://github.com/apache/beam/issues/39875)).
* ClickHouseIO: support writing `Decimal(P, S)` / `Decimal32/64/128/256` columns (Java) ([#39840](https://github.com/apache/beam/issues/39840)).
* SolaceIO now supports reading and writing user properties (message metadata) (Java) ([#40099](https://github.com/apache/beam/issues/40099)).

## New Features / Improvements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@
import com.solacesystems.jcsmp.BytesMessage;
import com.solacesystems.jcsmp.BytesXMLMessage;
import com.solacesystems.jcsmp.JCSMPFactory;
import com.solacesystems.jcsmp.SDTException;
import com.solacesystems.jcsmp.SDTMap;
import com.solacesystems.jcsmp.TextMessage;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.apache.beam.sdk.schemas.AutoValueSchema;
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
Expand Down Expand Up @@ -276,6 +281,16 @@ public enum PayloadType {
@SchemaFieldNumber("13")
public abstract PayloadType getPayloadType();

/**
* Gets the user properties of the message as a string map.
*
* <p>Mapped from {@link BytesXMLMessage#getProperties()}. Values are stringified.
*
* @return The user properties, or an empty map if the message carries none.
*/
@SchemaFieldNumber("14")
public abstract Map<String, String> getUserProperties();

/** Gets the payload decoded as UTF-8 when this record has type {@link PayloadType#TEXT}. */
public final String getText() {
if (getPayloadType() != PayloadType.TEXT) {
Expand All @@ -292,7 +307,8 @@ public static Builder builder() {
.setRedelivered(false)
.setTimeToLive(0)
.setAttachmentBytes(new byte[0])
.setPayloadType(PayloadType.BYTES_XML);
.setPayloadType(PayloadType.BYTES_XML)
.setUserProperties(Collections.emptyMap());
}

@AutoValue.Builder
Expand Down Expand Up @@ -332,6 +348,8 @@ public abstract Builder setReplicationGroupMessageId(

public abstract Builder setAttachmentBytes(byte[] attachmentBytes);

public abstract Builder setUserProperties(Map<String, String> userProperties);

public abstract Record build();
}

Expand Down Expand Up @@ -456,6 +474,7 @@ public static class SolaceRecordMapper {

Destination replyTo = getDestination(msg.getCorrelationId(), msg.getReplyTo());
Destination destination = getDestination(msg.getCorrelationId(), msg.getDestination());
Map<String, String> userProperties = getUserProperties(msg.getProperties());

Record.Builder recordBuilder = decodePayload(msg);
return recordBuilder
Expand All @@ -473,6 +492,7 @@ public static class SolaceRecordMapper {
msg.getReplicationGroupMessageId() != null
? msg.getReplicationGroupMessageId().toString()
: null)
.setUserProperties(userProperties)
.build();
}

Expand Down Expand Up @@ -519,6 +539,10 @@ public static BytesXMLMessage toMessage(Record record) {
msg.setSenderTimestamp(senderTimestamp);
msg.setApplicationMessageId(record.getMessageId());

if (!record.getUserProperties().isEmpty()) {
msg.setProperties(createUserProperties(record.getUserProperties()));
}

return msg;
}

Expand Down Expand Up @@ -599,5 +623,47 @@ private static byte[] readAttachment(BytesXMLMessage msg) {
buffer.get(attachment);
return attachment;
}

private static Map<String, String> getUserProperties(@Nullable SDTMap properties) {
if (properties == null || properties.isEmpty()) {
return Collections.emptyMap();
}

Map<String, String> userProperties = new HashMap<>();
for (String key : properties.keySet()) {
String value = stringifyUserProperty(properties, key);
if (value == null) {
LOG.warn("User property '{}' has a null value, skipping.", key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this may be excessive, would skip this log.

continue;
}
userProperties.put(key, value);
}
return Collections.unmodifiableMap(userProperties);
}

private static @Nullable String stringifyUserProperty(SDTMap properties, String key) {
try {
Object value = properties.get(key);
if (value == null) {
return null;
}
return String.valueOf(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will invoke toString but some types (destination, stream, byte array) that are part of SDTMap don't have it and this will run poorly for those, some specialized approach should be used for those like
Destination type maybe getName() should be invoked and for Stream maybe byte array and for byte array you should somehow preserve those bytes so its' not becoming garbage

@ngibanel ngibanel Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch I didn't check all the types. Maybe translating all types into string is not the good choice at the end because with this design we won't be able to reverse to Solace types.

@stankiewicz what do you think if instead having a Map<String, String>, having a Map<String, UserPropertyValue> where UserPropertyValue will be a beam schema compatible model that supports all the type kinds :

  @AutoValue
  @DefaultSchema(AutoValueSchema.class)
  public abstract static class UserPropertyValue {
    public enum Kind {
      BOOLEAN,
      BYTE,
      SHORT,
      INTEGER,
      LONG,
      FLOAT,
      DOUBLE,
      CHARACTER,
      STRING,
      BYTES,
      TOPIC,
      QUEUE,
      MAP,
      STREAM
    }

    public abstract Kind getKind();

    public abstract @Nullable Map<String, UserPropertyValue> getMapValue();

    public abstract @Nullable List<UserPropertyValue> getStreamValue();
    
    ...
 }

} catch (SDTException e) {
LOG.error("Could not read user property '{}'.", key, e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ngibanel this will cause metadata loss as message will be acked. Maybe rethrowing will be better?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, that means the consumer must enable the dead message queue in Solace (which is a best practice) to avoid losing messages if metadata cannot be deserialized. Otherwise the broker will redeliver the message until the max retry count is reached and then message will be discarded and lost.

return null;
}
}

private static SDTMap createUserProperties(Map<String, String> userProperties) {
SDTMap properties = JCSMPFactory.onlyInstance().createMap();
for (Map.Entry<String, String> entry : userProperties.entrySet()) {
try {
properties.putString(entry.getKey(), entry.getValue());
} catch (SDTException e) {
LOG.error("Could not write user property '{}'.", entry.getKey(), e);
}
}
return properties;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@
import com.solacesystems.jcsmp.BytesXMLMessage;
import com.solacesystems.jcsmp.DeliveryMode;
import com.solacesystems.jcsmp.JCSMPFactory;
import com.solacesystems.jcsmp.SDTMap;
import com.solacesystems.jcsmp.TextMessage;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.apache.beam.sdk.io.solace.broker.MessageProducerUtils;
import org.apache.beam.sdk.io.solace.data.Solace.Record;
import org.apache.beam.sdk.io.solace.data.Solace.Record.PayloadType;
Expand Down Expand Up @@ -144,6 +148,34 @@ public void testMapMessageMetadata() {
assertEquals(789L, record.getTimeToLive());
}

@Test
public void testMapMessageUserProperties() throws Exception {
BytesXMLMessage message = JCSMPFactory.onlyInstance().createBytesXMLMessage();
message.setApplicationMessageId("id");
SDTMap properties = JCSMPFactory.onlyInstance().createMap();
properties.putString("contentType", "application/json");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cover all SDTMap types

properties.putInteger("attempt", 3);
properties.putString("null", null);
message.setProperties(properties);

Record record = Solace.SolaceRecordMapper.toRecord(message);

Map<String, String> expected = new HashMap<>();
expected.put("contentType", "application/json");
expected.put("attempt", "3");
assertEquals(expected, record.getUserProperties());
}

@Test
public void testMapWithEmptyMessageUserProperties() {
BytesXMLMessage message = JCSMPFactory.onlyInstance().createBytesXMLMessage();
message.setApplicationMessageId("id");

Record record = Solace.SolaceRecordMapper.toRecord(message);

assertTrue(record.getUserProperties().isEmpty());
}

@Test
public void testMapTextRecord() {
Record record =
Expand Down Expand Up @@ -257,9 +289,52 @@ public void testToMessageDoesNotSetPublishingFields() {
assertNull(msg.getCorrelationKey());
}

@Test
public void testMapRecordUserProperties() throws Exception {
Record record =
Record.builder()
.setMessageId("id")
.setText("hello")
.setSenderTimestamp(1L)
.setUserProperties(Collections.singletonMap("contentType", "application/json"))
.build();

BytesXMLMessage msg = Solace.SolaceRecordMapper.toMessage(record);

assertEquals("application/json", msg.getProperties().getString("contentType"));
}

@Test
public void testMapWithEmptyRecordUserProperties() {
Record record =
Record.builder().setMessageId("id").setText("hello").setSenderTimestamp(1L).build();

BytesXMLMessage msg = Solace.SolaceRecordMapper.toMessage(record);

assertNull(msg.getProperties());
}

// ---------------------------------------------------------------------------
// round-trip
// ---------------------------------------------------------------------------
@Test
public void testRoundTripUserProperties() {
Record original =
Record.builder()
.setMessageId("id")
.setText("hello")
.setSenderTimestamp(1L)
.setUserProperties(Collections.singletonMap("contentType", "application/json"))
.build();

BytesXMLMessage msg = Solace.SolaceRecordMapper.toMessage(original);
msg.setApplicationMessageId("id");
Record decoded = Solace.SolaceRecordMapper.toRecord(msg);

assertEquals(
Collections.singletonMap("contentType", "application/json"), decoded.getUserProperties());
}

@Test
public void testRoundTripTextPayload() {
Record original =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import java.nio.charset.StandardCharsets;
import java.util.Collections;
import org.apache.beam.sdk.io.solace.data.Solace.Record;
import org.junit.Test;

Expand All @@ -33,6 +35,25 @@ public void testDefaultPayloadType() {
assertEquals(Record.PayloadType.BYTES_XML, record.getPayloadType());
}

@Test
public void testDefaultUserPropertiesIsEmpty() {
Record record = Record.builder().setMessageId("id").setPayload(new byte[0]).build();

assertTrue(record.getUserProperties().isEmpty());
}

@Test
public void testSetUserProperties() {
Record record =
Record.builder()
.setMessageId("id")
.setPayload(new byte[0])
.setUserProperties(Collections.singletonMap("key", "value"))
.build();

assertEquals(Collections.singletonMap("key", "value"), record.getUserProperties());
}

@Test
public void testSetTextPayload() {
Record record = Record.builder().setMessageId("id").setText("héllo").build();
Expand Down
Loading