This is an automated email from the ASF dual-hosted git repository.

Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 2ee432b2b61 Support JmsIO SchemaTransform and cross-lang (#39437)
2ee432b2b61 is described below

commit 2ee432b2b6101ef63b3b8d10aa7b9502e5fc36be
Author: Yi Hu <[email protected]>
AuthorDate: Tue Jul 28 16:23:10 2026 -0400

    Support JmsIO SchemaTransform and cross-lang (#39437)
    
    * [Gemini] Support JmsIO SchemaTransform and cross-lang
    
    * Adjust timing; clean up suppresswarnings
---
 ...m_PostCommit_Python_Xlang_Messaging_Direct.json |   2 +-
 .../prism/internal/engine/elementmanager.go        |   1 +
 .../java/org/apache/beam/sdk/io/jms/JmsIO.java     | 145 ++++++++++++
 .../sdk/io/jms/JmsReadSchemaTransformProvider.java | 228 ++++++++++++++++++
 .../io/jms/JmsWriteSchemaTransformProvider.java    | 192 +++++++++++++++
 .../sdk/io/jms/JmsSchemaTransformProviderTest.java | 263 +++++++++++++++++++++
 .../io/messaging-expansion-service/build.gradle    |   3 +
 .../apache_beam/io/external/xlang_jmsio_it_test.py | 235 ++++++++++++++++++
 .../io/external/xlang_mqttio_it_test.py            |  66 +++---
 sdks/python/test-suites/direct/common.gradle       |  21 +-
 sdks/standard_expansion_services.yaml              |   4 +
 sdks/standard_external_transforms.yaml             | 103 +++++++-
 12 files changed, 1220 insertions(+), 43 deletions(-)

diff --git 
a/.github/trigger_files/beam_PostCommit_Python_Xlang_Messaging_Direct.json 
b/.github/trigger_files/beam_PostCommit_Python_Xlang_Messaging_Direct.json
index e0266d62f2e..f1ba03a243e 100644
--- a/.github/trigger_files/beam_PostCommit_Python_Xlang_Messaging_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Python_Xlang_Messaging_Direct.json
@@ -1,4 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "modification": 4
+  "modification": 5
 }
diff --git a/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go 
b/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go
index 3949d1af324..2b502c679db 100644
--- a/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go
+++ b/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go
@@ -922,6 +922,7 @@ func (em *ElementManager) PersistBundle(rb RunBundle, 
col2Coders map[string]PCol
        // ProcessingTime timers need to be scheduled into the processing time 
based queue.
        newHolds, ptRefreshes := em.triageTimers(d, inputInfo, stage)
 
+       // TODO(https://github.com/apache/beam/issues/39446)
        // Return unprocessed to this stage's pending
        // TODO sort out pending element watermark holds for process 
continuation residuals.
        unprocessedElements := reElementResiduals(residuals.Data, inputInfo, rb)
diff --git 
a/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java 
b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java
index 8926d584a13..94024877320 100644
--- a/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java
+++ b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java
@@ -56,6 +56,9 @@ import org.apache.beam.sdk.metrics.Counter;
 import org.apache.beam.sdk.metrics.Metrics;
 import org.apache.beam.sdk.options.ExecutorOptions;
 import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
 import org.apache.beam.sdk.transforms.DoFn;
 import org.apache.beam.sdk.transforms.PTransform;
 import org.apache.beam.sdk.transforms.ParDo;
@@ -73,6 +76,7 @@ import org.apache.beam.sdk.values.TupleTag;
 import org.apache.beam.sdk.values.TupleTagList;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings;
 import org.checkerframework.checker.initialization.qual.Initialized;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.joda.time.Duration;
@@ -208,6 +212,117 @@ public class JmsIO {
     return new AutoValue_JmsIO_Write.Builder<EventT>().build();
   }
 
+  /** A POJO describing a JMS connection. */
+  @DefaultSchema(AutoValueSchema.class)
+  @AutoValue
+  public abstract static class ConnectionConfiguration implements Serializable 
{
+    public static Builder builder() {
+      return new AutoValue_JmsIO_ConnectionConfiguration.Builder();
+    }
+
+    public static ConnectionConfiguration create(
+        String serverUri, @Nullable String connectionFactoryClassName) {
+      checkArgument(serverUri != null, "serverUri can not be null");
+      return builder()
+          .setServerUri(serverUri)
+          .setConnectionFactoryClassName(connectionFactoryClassName)
+          .build();
+    }
+
+    public static ConnectionConfiguration create(String serverUri) {
+      return create(serverUri, null);
+    }
+
+    @SchemaFieldDescription("The JMS broker URI.")
+    public abstract String getServerUri();
+
+    @SchemaFieldDescription("The JMS ConnectionFactory class name.")
+    public abstract @Nullable String getConnectionFactoryClassName();
+
+    @SchemaFieldDescription("The username to connect to the JMS broker.")
+    public abstract @Nullable String getUsername();
+
+    @SchemaFieldDescription("The password to connect to the JMS broker.")
+    public abstract @Nullable String getPassword();
+
+    public ConnectionConfiguration withUsername(String username) {
+      return toBuilder().setUsername(username).build();
+    }
+
+    public ConnectionConfiguration withPassword(String password) {
+      return toBuilder().setPassword(password).build();
+    }
+
+    public ConnectionConfiguration withConnectionFactoryClassName(
+        String connectionFactoryClassName) {
+      return 
toBuilder().setConnectionFactoryClassName(connectionFactoryClassName).build();
+    }
+
+    abstract Builder toBuilder();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder setServerUri(String serverUri);
+
+      public abstract Builder setConnectionFactoryClassName(
+          @Nullable String connectionFactoryClassName);
+
+      public abstract Builder setUsername(@Nullable String username);
+
+      public abstract Builder setPassword(@Nullable String password);
+
+      public abstract ConnectionConfiguration build();
+    }
+
+    public ConnectionFactory createConnectionFactory() {
+      String className = getConnectionFactoryClassName();
+      // Default to ActiveMQ
+      if (Strings.isNullOrEmpty(className)) {
+        className = "org.apache.activemq.ActiveMQConnectionFactory";
+      }
+      try {
+        Class<?> clazz = Class.forName(className);
+        String uri = getServerUri();
+        String username = getUsername();
+        String password = getPassword();
+
+        if (username != null && password != null) {
+          try {
+            return (ConnectionFactory)
+                clazz
+                    .getConstructor(String.class, String.class, String.class)
+                    .newInstance(username, password, uri);
+          } catch (NoSuchMethodException e) {
+            // fall through to 1-arg constructor + setters
+          }
+        }
+        try {
+          ConnectionFactory cf =
+              (ConnectionFactory) 
clazz.getConstructor(String.class).newInstance(uri);
+          if (username != null && password != null) {
+            try {
+              clazz.getMethod("setUserName", String.class).invoke(cf, 
username);
+              clazz.getMethod("setPassword", String.class).invoke(cf, 
password);
+            } catch (NoSuchMethodException e) {
+              try {
+                clazz.getMethod("setUsername", String.class).invoke(cf, 
username);
+                clazz.getMethod("setPassword", String.class).invoke(cf, 
password);
+              } catch (NoSuchMethodException e2) {
+                // ignore if setters not found
+              }
+            }
+          }
+          return cf;
+        } catch (NoSuchMethodException e) {
+          return (ConnectionFactory) clazz.getConstructor().newInstance();
+        }
+      } catch (Exception e) {
+        throw new IllegalArgumentException(
+            "Unable to instantiate JMS ConnectionFactory of class " + 
className, e);
+      }
+    }
+  }
+
   public interface ConnectionFactoryContainer<T extends 
ConnectionFactoryContainer<T>> {
 
     T withConnectionFactory(ConnectionFactory connectionFactory);
@@ -367,6 +482,21 @@ public class JmsIO {
       return 
builder().setConnectionFactoryProviderFn(connectionFactoryProviderFn).build();
     }
 
+    public Read<T> withConnectionConfiguration(ConnectionConfiguration 
configuration) {
+      checkArgument(configuration != null, "configuration can not be null");
+      Read<T> read =
+          this.withConnectionFactoryProviderFn(
+              (SerializableFunction<Void, ? extends ConnectionFactory>)
+                  __ -> configuration.createConnectionFactory());
+      if (configuration.getUsername() != null) {
+        read = read.withUsername(configuration.getUsername());
+      }
+      if (configuration.getPassword() != null) {
+        read = read.withPassword(configuration.getPassword());
+      }
+      return read;
+    }
+
     /**
      * Specify the JMS queue destination name where to read messages from. The 
{@link JmsIO.Read}
      * acts as a consumer on the queue.
@@ -1106,6 +1236,21 @@ public class JmsIO {
       return 
builder().setConnectionFactoryProviderFn(connectionFactoryProviderFn).build();
     }
 
+    public Write<EventT> withConnectionConfiguration(ConnectionConfiguration 
configuration) {
+      checkArgument(configuration != null, "configuration can not be null");
+      Write<EventT> write =
+          this.withConnectionFactoryProviderFn(
+              (SerializableFunction<Void, ? extends ConnectionFactory>)
+                  __ -> configuration.createConnectionFactory());
+      if (configuration.getUsername() != null) {
+        write = write.withUsername(configuration.getUsername());
+      }
+      if (configuration.getPassword() != null) {
+        write = write.withPassword(configuration.getPassword());
+      }
+      return write;
+    }
+
     /**
      * Specify the JMS queue destination name where to send messages to. The 
{@link JmsIO.Write}
      * acts as a producer on the queue.
diff --git 
a/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsReadSchemaTransformProvider.java
 
b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsReadSchemaTransformProvider.java
new file mode 100644
index 00000000000..79643da3327
--- /dev/null
+++ 
b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsReadSchemaTransformProvider.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.jms;
+
+import static org.apache.beam.sdk.io.jms.JmsIO.ConnectionConfiguration;
+import static 
org.apache.beam.sdk.io.jms.JmsReadSchemaTransformProvider.ReadConfiguration;
+
+import com.google.auto.service.AutoService;
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.TypedSchemaTransformProvider;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.Row;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
+import org.joda.time.Duration;
+
+@AutoService(SchemaTransformProvider.class)
+public class JmsReadSchemaTransformProvider
+    extends TypedSchemaTransformProvider<ReadConfiguration> {
+  @DefaultSchema(AutoValueSchema.class)
+  @AutoValue
+  public abstract static class ReadConfiguration implements Serializable {
+    public static Builder builder() {
+      return new 
AutoValue_JmsReadSchemaTransformProvider_ReadConfiguration.Builder();
+    }
+
+    @SchemaFieldDescription(
+        "Configuration options to set up the JMS connection.\nNote: if "
+            + "connection factory class name is set, spin up a persistent 
expansion service with "
+            + "the provider client JAR on the classpath:\n"
+            + "java -cp <expansion-service-jar>:<provider-client-jar>\n"
+            + "org.apache.beam.sdk.expansion.service.ExpansionService <port>\n"
+            + "and pass expansion_service='localhost:<port>' to the transform. 
Currently, only "
+            + "ActiveMQ (org.apache.activemq.ActiveMQConnectionFactory) is 
embedded into the expansion service")
+    public abstract ConnectionConfiguration getConnectionConfiguration();
+
+    @SchemaFieldDescription(
+        "The JMS queue to read from. Exclusively one of queue or topic must be 
specified.")
+    @Nullable
+    public abstract String getQueue();
+
+    @SchemaFieldDescription(
+        "The JMS topic to read from. Exclusively one of queue or topic must be 
specified.")
+    @Nullable
+    public abstract String getTopic();
+
+    @SchemaFieldDescription(
+        "The max number of records to receive. Setting this will result in a 
bounded PCollection.")
+    @Nullable
+    public abstract Long getMaxNumRecords();
+
+    @SchemaFieldDescription(
+        "The maximum time for this source to read messages. Setting this will 
result in a bounded PCollection.")
+    @Nullable
+    public abstract Long getMaxReadTimeSeconds();
+
+    @SchemaFieldDescription("Close timeout for the JMS connection in seconds.")
+    @Nullable
+    public abstract Long getCloseTimeoutSeconds();
+
+    @SchemaFieldDescription(
+        "The JMS acknowledge mode: CLIENT_ACKNOWLEDGE, 
CLIENT_ACKNOWLEDGE_UNSAFE, or INDIVIDUAL_ACKNOWLEDGE.")
+    @Nullable
+    public abstract String getAcknowledgeMode();
+
+    @SchemaFieldDescription(
+        "The proprietary integer code for individual message acknowledgment 
when using INDIVIDUAL_ACKNOWLEDGE.")
+    @Nullable
+    public abstract Integer getIndividualAcknowledgeModeCode();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder setConnectionConfiguration(
+          ConnectionConfiguration connectionConfiguration);
+
+      public abstract Builder setQueue(String queue);
+
+      public abstract Builder setTopic(String topic);
+
+      public abstract Builder setMaxNumRecords(Long maxNumRecords);
+
+      public abstract Builder setMaxReadTimeSeconds(Long maxReadTimeSeconds);
+
+      public abstract Builder setCloseTimeoutSeconds(Long closeTimeoutSeconds);
+
+      public abstract Builder setAcknowledgeMode(String acknowledgeMode);
+
+      public abstract Builder setIndividualAcknowledgeModeCode(
+          Integer individualAcknowledgeModeCode);
+
+      public abstract ReadConfiguration build();
+    }
+  }
+
+  @Override
+  public String identifier() {
+    return "beam:schematransform:org.apache.beam:jms_read:v1";
+  }
+
+  @Override
+  public String description() {
+    return "Reads messages from a JMS broker and outputs each message as a 
single `payload` "
+        + "(string) field.\n"
+        + "\n"
+        + "By default the read is unbounded (streaming): it keeps consuming 
messages from the "
+        + "specified queue or topic until the pipeline is stopped. Setting 
`maxNumRecords` and/or "
+        + "`maxReadTimeSeconds` bounds the read, producing a bounded (batch) 
PCollection.";
+  }
+
+  @Override
+  public List<String> outputCollectionNames() {
+    return Collections.singletonList("output");
+  }
+
+  @Override
+  protected SchemaTransform from(ReadConfiguration configuration) {
+    return new JmsReadSchemaTransform(configuration);
+  }
+
+  private static class JmsReadSchemaTransform extends SchemaTransform {
+    private final ReadConfiguration config;
+
+    JmsReadSchemaTransform(ReadConfiguration configuration) {
+      this.config = configuration;
+    }
+
+    @Override
+    public PCollectionRowTuple expand(PCollectionRowTuple input) {
+      Preconditions.checkState(
+          input.getAll().isEmpty(),
+          "Expected zero input PCollections for this source, but found: %s",
+          input.getAll().keySet());
+
+      Preconditions.checkArgument(
+          (config.getQueue() != null) != (config.getTopic() != null),
+          "Exactly one of queue or topic must be specified.");
+
+      JmsIO.Read<JmsRecord> readTransform =
+          
JmsIO.read().withConnectionConfiguration(config.getConnectionConfiguration());
+
+      String queue = config.getQueue();
+      if (queue != null) {
+        readTransform = readTransform.withQueue(queue);
+      }
+      String topic = config.getTopic();
+      if (topic != null) {
+        readTransform = readTransform.withTopic(topic);
+      }
+
+      Long maxRecords = config.getMaxNumRecords();
+      Long maxReadTime = config.getMaxReadTimeSeconds();
+      if (maxRecords != null) {
+        readTransform = readTransform.withMaxNumRecords(maxRecords);
+      }
+      if (maxReadTime != null) {
+        readTransform = 
readTransform.withMaxReadTime(Duration.standardSeconds(maxReadTime));
+      }
+
+      Long closeTimeout = config.getCloseTimeoutSeconds();
+      if (closeTimeout != null) {
+        readTransform = 
readTransform.withCloseTimeout(Duration.standardSeconds(closeTimeout));
+      }
+
+      String ackMode = config.getAcknowledgeMode();
+      if (ackMode != null) {
+        readTransform =
+            
readTransform.withAcknowledgeMode(JmsIO.AcknowledgeMode.valueOf(ackMode.toUpperCase()));
+      }
+
+      Integer indAckCode = config.getIndividualAcknowledgeModeCode();
+      if (indAckCode != null) {
+        readTransform = 
readTransform.withIndividualAcknowledgeModeCode(indAckCode);
+      }
+
+      Schema outputSchema = Schema.builder().addStringField("payload").build();
+
+      PCollection<Row> outputRows =
+          input
+              .getPipeline()
+              .apply(readTransform)
+              .apply(
+                  "Wrap in Beam Rows",
+                  ParDo.of(
+                      new DoFn<JmsRecord, Row>() {
+                        @ProcessElement
+                        public void processElement(
+                            @Element JmsRecord record, OutputReceiver<Row> 
outputReceiver) {
+                          String payload = record.getPayload();
+                          if (payload == null) {
+                            payload = "";
+                          }
+                          outputReceiver.output(
+                              
Row.withSchema(outputSchema).addValue(payload).build());
+                        }
+                      }))
+              .setRowSchema(outputSchema);
+
+      return PCollectionRowTuple.of("output", outputRows);
+    }
+  }
+}
diff --git 
a/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsWriteSchemaTransformProvider.java
 
b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsWriteSchemaTransformProvider.java
new file mode 100644
index 00000000000..5db7271b4b9
--- /dev/null
+++ 
b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsWriteSchemaTransformProvider.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.jms;
+
+import static org.apache.beam.sdk.io.jms.JmsIO.ConnectionConfiguration;
+import static 
org.apache.beam.sdk.io.jms.JmsWriteSchemaTransformProvider.WriteConfiguration;
+
+import com.google.auto.service.AutoService;
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.TypedSchemaTransformProvider;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.Row;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
+
+@AutoService(SchemaTransformProvider.class)
+public class JmsWriteSchemaTransformProvider
+    extends TypedSchemaTransformProvider<WriteConfiguration> {
+  @DefaultSchema(AutoValueSchema.class)
+  @AutoValue
+  public abstract static class WriteConfiguration implements Serializable {
+    public static Builder builder() {
+      return new 
AutoValue_JmsWriteSchemaTransformProvider_WriteConfiguration.Builder();
+    }
+
+    @SchemaFieldDescription(
+        "Configuration options to set up the JMS connection.\nNote: if "
+            + "connection factory class name is set, spin up a persistent 
expansion service with "
+            + "the provider client JAR on the classpath:\n"
+            + "java -cp <expansion-service-jar>:<provider-client-jar>\n"
+            + "org.apache.beam.sdk.expansion.service.ExpansionService <port>\n"
+            + "and pass expansion_service='localhost:<port>' to the transform. 
Currently, only "
+            + "ActiveMQ (org.apache.activemq.ActiveMQConnectionFactory) is 
embedded into the expansion service")
+    public abstract ConnectionConfiguration getConnectionConfiguration();
+
+    @SchemaFieldDescription(
+        "The JMS queue to write to. Exclusively one of queue or topic must be 
specified.")
+    @Nullable
+    public abstract String getQueue();
+
+    @SchemaFieldDescription(
+        "The JMS topic to write to. Exclusively one of queue or topic must be 
specified.")
+    @Nullable
+    public abstract String getTopic();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder setConnectionConfiguration(
+          ConnectionConfiguration connectionConfiguration);
+
+      public abstract Builder setQueue(String queue);
+
+      public abstract Builder setTopic(String topic);
+
+      public abstract WriteConfiguration build();
+    }
+  }
+
+  @Override
+  public String identifier() {
+    return "beam:schematransform:org.apache.beam:jms_write:v1";
+  }
+
+  @Override
+  public String description() {
+    return "Publishes messages to a JMS broker. Expects an input PCollection 
of rows with a "
+        + "`payload` (string) or `bytes` field, each of which is published as 
one JMS TextMessage.\n"
+        + "\n"
+        + "Works with both bounded (batch) and unbounded (streaming) input 
PCollections.";
+  }
+
+  @Override
+  public List<String> inputCollectionNames() {
+    return Collections.singletonList("input");
+  }
+
+  @Override
+  protected SchemaTransform from(WriteConfiguration configuration) {
+    return new JmsWriteSchemaTransform(configuration);
+  }
+
+  private static class JmsWriteSchemaTransform extends SchemaTransform {
+    private final WriteConfiguration config;
+
+    JmsWriteSchemaTransform(WriteConfiguration configuration) {
+      this.config = configuration;
+    }
+
+    @Override
+    public PCollectionRowTuple expand(PCollectionRowTuple input) {
+      PCollection<Row> inputRows = input.getSinglePCollection();
+
+      Preconditions.checkArgument(
+          (config.getQueue() != null) != (config.getTopic() != null),
+          "Exactly one of queue or topic must be specified.");
+
+      int fieldIndex = -1;
+      boolean isBytes = false;
+      Schema schema = inputRows.getSchema();
+      if (schema.hasField("payload")
+          && 
schema.getField("payload").getType().equals(Schema.FieldType.STRING)) {
+        fieldIndex = schema.indexOf("payload");
+      } else if (schema.hasField("bytes")
+          && 
schema.getField("bytes").getType().equals(Schema.FieldType.BYTES)) {
+        fieldIndex = schema.indexOf("bytes");
+        isBytes = true;
+      } else if (schema.getFieldCount() == 1
+          && schema.getField(0).getType().equals(Schema.FieldType.STRING)) {
+        fieldIndex = 0;
+      } else if (schema.getFieldCount() == 1
+          && schema.getField(0).getType().equals(Schema.FieldType.BYTES)) {
+        fieldIndex = 0;
+        isBytes = true;
+      } else {
+        throw new IllegalStateException(
+            String.format(
+                "Expected input Schema to have a 'payload' (STRING) or 'bytes' 
(BYTES) field, or"
+                    + " a single string/bytes field, but received: %s",
+                schema));
+      }
+
+      JmsIO.Write<String> writeTransform =
+          JmsIO.<String>write()
+              .withConnectionConfiguration(config.getConnectionConfiguration())
+              .withValueMapper(new TextMessageMapper());
+
+      String queue = config.getQueue();
+      if (queue != null) {
+        writeTransform = writeTransform.withQueue(queue);
+      }
+      String topic = config.getTopic();
+      if (topic != null) {
+        writeTransform = writeTransform.withTopic(topic);
+      }
+
+      final int idx = fieldIndex;
+      final boolean bytesField = isBytes;
+      inputRows
+          .apply(
+              "Extract payload",
+              ParDo.of(
+                  new DoFn<Row, String>() {
+                    @ProcessElement
+                    public void processElement(
+                        @Element Row row, OutputReceiver<String> 
outputReceiver) {
+                      if (bytesField) {
+                        byte[] bytes =
+                            
org.apache.beam.sdk.util.Preconditions.checkStateNotNull(
+                                row.getBytes(idx));
+                        outputReceiver.output(new String(bytes, 
StandardCharsets.UTF_8));
+                      } else {
+                        String payload =
+                            
org.apache.beam.sdk.util.Preconditions.checkStateNotNull(
+                                row.getString(idx));
+                        outputReceiver.output(payload);
+                      }
+                    }
+                  }))
+          .apply(writeTransform);
+
+      return PCollectionRowTuple.empty(inputRows.getPipeline());
+    }
+  }
+}
diff --git 
a/sdks/java/io/jms/src/test/java/org/apache/beam/sdk/io/jms/JmsSchemaTransformProviderTest.java
 
b/sdks/java/io/jms/src/test/java/org/apache/beam/sdk/io/jms/JmsSchemaTransformProviderTest.java
new file mode 100644
index 00000000000..b354ef94a00
--- /dev/null
+++ 
b/sdks/java/io/jms/src/test/java/org/apache/beam/sdk/io/jms/JmsSchemaTransformProviderTest.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.jms;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import 
org.apache.beam.sdk.io.jms.JmsReadSchemaTransformProvider.ReadConfiguration;
+import 
org.apache.beam.sdk.io.jms.JmsWriteSchemaTransformProvider.WriteConfiguration;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.Row;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link JmsReadSchemaTransformProvider} and {@link 
JmsWriteSchemaTransformProvider}. */
+@RunWith(JUnit4.class)
+public class JmsSchemaTransformProviderTest {
+
+  @Rule
+  public final transient TestPipeline pipeline =
+      TestPipeline.create().enableAbandonedNodeEnforcement(false);
+
+  @Test
+  public void testReadFindTransform() {
+    ServiceLoader<SchemaTransformProvider> serviceLoader =
+        ServiceLoader.load(SchemaTransformProvider.class);
+    List<SchemaTransformProvider> providers =
+        StreamSupport.stream(serviceLoader.spliterator(), false)
+            .filter(provider -> provider.getClass() == 
JmsReadSchemaTransformProvider.class)
+            .collect(Collectors.toList());
+    SchemaTransformProvider jmsProvider = providers.get(0);
+
+    assertEquals(Lists.newArrayList("output"), 
jmsProvider.outputCollectionNames());
+    assertEquals(Lists.newArrayList(), jmsProvider.inputCollectionNames());
+    assertEquals("beam:schematransform:org.apache.beam:jms_read:v1", 
jmsProvider.identifier());
+    assertNotNull(jmsProvider.description());
+
+    assertEquals(
+        Sets.newHashSet(
+            "connection_configuration",
+            "queue",
+            "topic",
+            "max_num_records",
+            "max_read_time_seconds",
+            "close_timeout_seconds",
+            "acknowledge_mode",
+            "individual_acknowledge_mode_code"),
+        jmsProvider.configurationSchema().getFields().stream()
+            .map(Schema.Field::getName)
+            .collect(Collectors.toSet()));
+  }
+
+  @Test
+  public void testReadBuildTransformWithQueue() {
+    ReadConfiguration readConfig =
+        ReadConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setQueue("TEST_QUEUE")
+            .setMaxNumRecords(100L)
+            .setMaxReadTimeSeconds(5L)
+            .setCloseTimeoutSeconds(10L)
+            .setAcknowledgeMode("CLIENT_ACKNOWLEDGE")
+            .setIndividualAcknowledgeModeCode(101)
+            .build();
+
+    SchemaTransform transform = new 
JmsReadSchemaTransformProvider().from(readConfig);
+    PCollectionRowTuple output = 
transform.expand(PCollectionRowTuple.empty(pipeline));
+
+    assertEquals(1, output.getAll().size());
+    assertTrue(output.has("output"));
+    assertEquals(
+        Schema.builder().addStringField("payload").build(), 
output.get("output").getSchema());
+  }
+
+  @Test
+  public void testReadBuildTransformWithTopic() {
+    ReadConfiguration readConfig =
+        ReadConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setTopic("TEST_TOPIC")
+            .build();
+
+    SchemaTransform transform = new 
JmsReadSchemaTransformProvider().from(readConfig);
+    PCollectionRowTuple output = 
transform.expand(PCollectionRowTuple.empty(pipeline));
+
+    assertEquals(1, output.getAll().size());
+    assertTrue(output.has("output"));
+    assertEquals(
+        Schema.builder().addStringField("payload").build(), 
output.get("output").getSchema());
+  }
+
+  @Test
+  public void testReadInvalidConfigurations() {
+    ReadConfiguration bothConfig =
+        ReadConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setQueue("TEST_QUEUE")
+            .setTopic("TEST_TOPIC")
+            .build();
+    SchemaTransform bothTransform = new 
JmsReadSchemaTransformProvider().from(bothConfig);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> bothTransform.expand(PCollectionRowTuple.empty(pipeline)));
+
+    ReadConfiguration neitherConfig =
+        ReadConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .build();
+    SchemaTransform neitherTransform = new 
JmsReadSchemaTransformProvider().from(neitherConfig);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> neitherTransform.expand(PCollectionRowTuple.empty(pipeline)));
+  }
+
+  @Test
+  public void testReadWithNonEmptyInputThrows() {
+    ReadConfiguration readConfig =
+        ReadConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setQueue("TEST_QUEUE")
+            .build();
+    SchemaTransform transform = new 
JmsReadSchemaTransformProvider().from(readConfig);
+
+    PCollection<Row> dummyInput =
+        pipeline.apply(
+            "CreateDummy", 
Create.empty(Schema.builder().addStringField("dummy").build()));
+    assertThrows(
+        IllegalStateException.class,
+        () -> transform.expand(PCollectionRowTuple.of("input", dummyInput)));
+  }
+
+  @Test
+  public void testWriteFindTransform() {
+    ServiceLoader<SchemaTransformProvider> serviceLoader =
+        ServiceLoader.load(SchemaTransformProvider.class);
+    List<SchemaTransformProvider> providers =
+        StreamSupport.stream(serviceLoader.spliterator(), false)
+            .filter(provider -> provider.getClass() == 
JmsWriteSchemaTransformProvider.class)
+            .collect(Collectors.toList());
+    SchemaTransformProvider jmsProvider = providers.get(0);
+
+    assertEquals(Lists.newArrayList(), jmsProvider.outputCollectionNames());
+    assertEquals(Lists.newArrayList("input"), 
jmsProvider.inputCollectionNames());
+    assertEquals("beam:schematransform:org.apache.beam:jms_write:v1", 
jmsProvider.identifier());
+    assertNotNull(jmsProvider.description());
+
+    assertEquals(
+        Sets.newHashSet("connection_configuration", "queue", "topic"),
+        jmsProvider.configurationSchema().getFields().stream()
+            .map(Schema.Field::getName)
+            .collect(Collectors.toSet()));
+  }
+
+  @Test
+  public void testWriteBuildTransformWithQueueAndTopic() {
+    WriteConfiguration queueConfig =
+        WriteConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setQueue("TEST_QUEUE")
+            .build();
+    SchemaTransform queueTransform = new 
JmsWriteSchemaTransformProvider().from(queueConfig);
+    Schema schema = Schema.builder().addStringField("payload").build();
+    PCollection<Row> inputRows = pipeline.apply("CreateQueueRows", 
Create.empty(schema));
+    PCollectionRowTuple output = 
queueTransform.expand(PCollectionRowTuple.of("input", inputRows));
+    assertTrue(output.getAll().isEmpty());
+
+    WriteConfiguration topicConfig =
+        WriteConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setTopic("TEST_TOPIC")
+            .build();
+    SchemaTransform topicTransform = new 
JmsWriteSchemaTransformProvider().from(topicConfig);
+    Schema bytesSchema = Schema.builder().addByteArrayField("bytes").build();
+    PCollection<Row> bytesRows = pipeline.apply("CreateTopicRows", 
Create.empty(bytesSchema));
+    PCollectionRowTuple topicOutput =
+        topicTransform.expand(PCollectionRowTuple.of("input", bytesRows));
+    assertTrue(topicOutput.getAll().isEmpty());
+  }
+
+  @Test
+  public void testWriteInvalidConfigurations() {
+    WriteConfiguration bothConfig =
+        WriteConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setQueue("TEST_QUEUE")
+            .setTopic("TEST_TOPIC")
+            .build();
+    SchemaTransform bothTransform = new 
JmsWriteSchemaTransformProvider().from(bothConfig);
+    Schema schema = Schema.builder().addStringField("payload").build();
+    PCollection<Row> inputRows = pipeline.apply("CreateBothRows", 
Create.empty(schema));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> bothTransform.expand(PCollectionRowTuple.of("input", 
inputRows)));
+
+    WriteConfiguration neitherConfig =
+        WriteConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .build();
+    SchemaTransform neitherTransform = new 
JmsWriteSchemaTransformProvider().from(neitherConfig);
+    PCollection<Row> inputRows2 = pipeline.apply("CreateNeitherRows", 
Create.empty(schema));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> neitherTransform.expand(PCollectionRowTuple.of("input", 
inputRows2)));
+  }
+
+  @Test
+  public void testWriteInvalidInputSchema() {
+    WriteConfiguration config =
+        WriteConfiguration.builder()
+            .setConnectionConfiguration(
+                JmsIO.ConnectionConfiguration.create("tcp://localhost:61616"))
+            .setQueue("TEST_QUEUE")
+            .build();
+    SchemaTransform transform = new 
JmsWriteSchemaTransformProvider().from(config);
+
+    Schema invalidSchema = 
Schema.builder().addInt32Field("id").addStringField("name").build();
+    PCollection<Row> inputRows =
+        pipeline.apply("CreateInvalidSchemaRows", Create.empty(invalidSchema));
+    assertThrows(
+        IllegalStateException.class,
+        () -> transform.expand(PCollectionRowTuple.of("input", inputRows)));
+  }
+}
diff --git a/sdks/java/io/messaging-expansion-service/build.gradle 
b/sdks/java/io/messaging-expansion-service/build.gradle
index 6cdf67b86d6..8693f4597bc 100644
--- a/sdks/java/io/messaging-expansion-service/build.gradle
+++ b/sdks/java/io/messaging-expansion-service/build.gradle
@@ -42,6 +42,9 @@ dependencies {
   permitUnusedDeclared project(":sdks:java:expansion-service") // BEAM-11761
   implementation project(":sdks:java:io:mqtt")
   permitUnusedDeclared project(":sdks:java:io:mqtt") // BEAM-11761
+  implementation project(":sdks:java:io:jms")
+  permitUnusedDeclared project(":sdks:java:io:jms") // BEAM-11761
+  runtimeOnly library.java.activemq_client
   runtimeOnly library.java.slf4j_jdk14
 }
 
diff --git a/sdks/python/apache_beam/io/external/xlang_jmsio_it_test.py 
b/sdks/python/apache_beam/io/external/xlang_jmsio_it_test.py
new file mode 100644
index 00000000000..98905107f05
--- /dev/null
+++ b/sdks/python/apache_beam/io/external/xlang_jmsio_it_test.py
@@ -0,0 +1,235 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Integration tests for the cross-language JMS IO transforms
+(ReadFromJms / WriteToJms), served by the messaging expansion service.
+
+Runs against an ActiveMQ broker started once per test class via testcontainers.
+"""
+
+import logging
+import threading
+import time
+import unittest
+
+import pytest
+
+import apache_beam as beam
+from apache_beam.options.pipeline_options import PortableOptions
+from apache_beam.options.pipeline_options import StandardOptions
+from apache_beam.testing.test_pipeline import TestPipeline
+from apache_beam.typehints.row_type import RowTypeConstraint
+
+# pylint: disable=wrong-import-order, wrong-import-position, ungrouped-imports
+try:
+  from apache_beam.io import ReadFromJms
+  from apache_beam.io import WriteToJms
+except ImportError:
+  ReadFromJms = None
+  WriteToJms = None
+
+try:
+  from testcontainers.core.container import DockerContainer
+  from testcontainers.core.waiting_utils import wait_for_logs
+except ImportError:
+  DockerContainer = None
+
+_LOGGER = logging.getLogger(__name__)
+NUM_RECORDS = 100
+STRING_ROW = RowTypeConstraint.from_fields([('payload', str)])
+
+
[email protected]_messaging_java_expansion_service
[email protected](
+    DockerContainer is None, 'testcontainers package is not installed')
[email protected](
+    ReadFromJms is None or WriteToJms is None,
+    'JMS cross-language wrappers are not generated')
[email protected](
+    TestPipeline().get_pipeline_options().view_as(StandardOptions).runner
+    is None,
+    'Do not run this test on precommit suites.')
[email protected](
+    'Dataflow' in (
+        TestPipeline().get_pipeline_options().view_as(StandardOptions).runner 
or
+        ''),
+    'The testcontainers broker is not reachable from Dataflow workers; '
+    'a Dataflow variant would need a remotely hosted JMS broker.')
+class CrossLanguageJmsIOTest(unittest.TestCase):
+  @classmethod
+  def setUpClass(cls):
+    cls.start_jms_container(retries=3)
+    host = cls.broker.get_container_host_ip()
+    port = cls.broker.get_exposed_port(61616)
+    cls.server_uri = 'tcp://%s:%s' % (host, port)
+
+  @classmethod
+  def tearDownClass(cls):
+    try:
+      cls.broker.stop()
+    except Exception:
+      logging.error('Could not stop the JMS broker container.')
+
+  @classmethod
+  def start_jms_container(cls, retries):
+    for i in range(retries):
+      try:
+        cls.broker = DockerContainer(
+            'apache/activemq-classic:5.18.3').with_exposed_ports(61616)
+        cls.broker.start()
+        wait_for_logs(cls.broker, '.*ActiveMQ .* started.*', timeout=30)
+        break
+      except Exception as e:
+        try:
+          cls.broker.stop()
+        except Exception:
+          pass
+        if i == retries - 1:
+          logging.error('Unable to initialize the JMS broker container.')
+          raise e
+
+  def _connection_configuration(self, connection_param=None):
+    uri = self.server_uri
+    if connection_param:
+      uri += '?' + connection_param
+    return {
+        'server_uri': uri,
+        'connection_factory_class_name': 
'org.apache.activemq.ActiveMQConnectionFactory'
+    }
+
+  def _run_streaming_test(
+      self,
+      source_queue,
+      sink_queue,
+      acknowledge_mode=None,
+      connection_param=None):
+    subscriber_result = {}
+
+    def produce(count):
+      container = self.broker.get_wrapped_container()
+      exit_code, _ = container.exec_run([
+          '/opt/apache-activemq/bin/activemq',
+          'producer',
+          '--destination',
+          'queue://' + source_queue,
+          '--messageCount',
+          str(count),
+          '--persistent',
+          'false'
+      ])
+      if exit_code == 0:
+        _LOGGER.info('published %s messages', count)
+      else:
+        _LOGGER.warning('publishing message returns exit code %s', exit_code)
+
+    def publish():
+      produce(remaining_records)
+
+    stop_event = threading.Event()
+
+    def subscribe():
+      # Poll the sink queue every few seconds until NUM_RECORDS messages arrive
+      # or timeout occurs, avoiding blocking indefinitely inside activemq 
consumer.
+      container = self.broker.get_wrapped_container()
+      received_messages = []
+      while len(received_messages) < NUM_RECORDS and not stop_event.is_set():
+        time.sleep(5)
+        try:
+          exit_code, output = container.exec_run([
+              '/opt/apache-activemq/bin/activemq',
+              'browse',
+              sink_queue
+          ])
+          if exit_code == 0 and output:
+            received_messages = [
+                line.split('JMS_BODY_FIELD:JMSText = ')[-1].strip()
+                for line in output.decode('utf-8').splitlines()
+                if 'JMS_BODY_FIELD:JMSText = ' in line
+            ]
+            subscriber_result['received'] = received_messages
+        except Exception as e:
+          _LOGGER.warning('Error while browsing sink queue: %s', e)
+          break
+      _LOGGER.info('received %s messages', len(received_messages))
+
+    # TODO(https://github.com/apache/beam/issues/39446): Clean up
+    # pre-publishing Prism runner issue resolved
+    initial_records = 10
+    remaining_records = NUM_RECORDS - initial_records
+    produce(initial_records)
+
+    publisher = threading.Thread(target=publish, daemon=True)
+    subscriber = threading.Thread(target=subscribe, daemon=True)
+
+    p = TestPipeline(blocking=False)
+    p.get_pipeline_options().view_as(StandardOptions).streaming = True
+    p.not_use_test_runner_api = True
+    # Run pipeline without blocking
+    # TODO: Remove once subprocess cache leak fixed for pipeline running
+    # in LOOPBACK mode outside of with clause
+    p.__enter__()
+    try:
+      _ = (
+          p
+          | 'ReadFromJms' >> ReadFromJms(
+              connection_configuration=self._connection_configuration(
+                  connection_param),
+              queue=source_queue,
+              acknowledge_mode=acknowledge_mode)
+          |
+          'Passthrough' >> beam.Map(lambda row: beam.Row(payload=row.payload)
+                                    ).with_output_types(STRING_ROW)
+          | 'WriteToJms' >> WriteToJms(
+              connection_configuration=self._connection_configuration(
+                  connection_param),
+              queue=sink_queue))
+      publisher.start()
+      result = p.run()
+      subscriber.start()
+      try:
+        subscriber.join(timeout=90)  # 1.5 min
+      finally:
+        stop_event.set()
+        publisher.join()
+        try:
+          result.cancel()
+        except Exception:  # pylint: disable=broad-except
+          _LOGGER.warning('Ignoring error while cancelling the pipeline.')
+    finally:
+      p._extra_context.__exit__(None, None, None)
+
+    received = subscriber_result.get('received', [])
+    self.assertEqual(len(received), NUM_RECORDS)
+    # there are identical records
+    self.assertEqual(len(set(received)), NUM_RECORDS - initial_records)
+
+  def test_xlang_jms_write_read_queue_ind_ack(self):
+    self._run_streaming_test(
+        source_queue='xlang-jms-ind-source',
+        sink_queue='xlang-jms-ind-sink',
+        acknowledge_mode='INDIVIDUAL_ACKNOWLEDGE')
+
+  def test_xlang_jms_write_read_queue(self):
+    self._run_streaming_test(
+        source_queue='xlang-jms-source',
+        sink_queue='xlang-jms-sink',
+        connection_param='jms.prefetchPolicy.all=0')
+
+
+if __name__ == '__main__':
+  logging.getLogger().setLevel(logging.INFO)
+  unittest.main()
diff --git a/sdks/python/apache_beam/io/external/xlang_mqttio_it_test.py 
b/sdks/python/apache_beam/io/external/xlang_mqttio_it_test.py
index 889096d1f22..dad85a688ec 100644
--- a/sdks/python/apache_beam/io/external/xlang_mqttio_it_test.py
+++ b/sdks/python/apache_beam/io/external/xlang_mqttio_it_test.py
@@ -214,46 +214,40 @@ class CrossLanguageMqttIOTest(unittest.TestCase):
     publisher.start()
     subscriber.start()
 
-    # MqttIO read is unbounded, so this pipeline runs in streaming mode and
-    # never terminates on its own. Amend the harness-provided pipeline options
-    # rather than discarding them: enable streaming, run non-blocking so the
-    # observe-then-cancel logic below can execute, and target the Prism 
portable
-    # runner. The latter is required because SwitchingDirectRunner disables its
-    # Prism delegation for pipelines containing external (cross-language)
-    # transforms (see runners/direct/direct_runner.py) and falls back to the
-    # BundleBasedDirectRunner, which cannot execute an unbounded read.
-    # The runner is instantiated during TestPipeline construction, so it must 
be
-    # passed to the constructor; the remaining harness-provided options are
-    # preserved and only amended (streaming + LOOPBACK environment) afterwards.
-    p = TestPipeline(runner='PrismRunner', blocking=False)
+    p = TestPipeline(blocking=False)
     p.get_pipeline_options().view_as(StandardOptions).streaming = True
-    p.get_pipeline_options().view_as(
-        PortableOptions).environment_type = 'LOOPBACK'
     p.not_use_test_runner_api = True
-    _ = (
-        p
-        | 'ReadFromMqtt' >> ReadFromMqtt(
-            connection_configuration=self._connection_configuration(
-                source_topic, 'xlang-mqtt-streaming-read'))
-        | 'Passthrough' >> beam.Map(
-            lambda row: beam.Row(bytes=row.bytes)).with_output_types(BYTES_ROW)
-        | 'WriteToMqtt' >> WriteToMqtt(
-            connection_configuration=self._connection_configuration(
-                sink_topic, 'xlang-mqtt-streaming-write')))
-    result = p.run()
+    # Run pipeline without blocking
+    # TODO: Remove once subprocess cache leak fixed for pipeline running
+    # in LOOPBACK mode outside of with clause
+    p.__enter__()
     try:
-      # The subscriber exits once NUM_RECORDS messages flowed through the
-      # streaming pipeline (or fails the assertions below on its timeout).
-      subscriber.join(timeout=200)
-    finally:
-      stop_publishing.set()
-      publisher.join()
+      _ = (
+          p
+          | 'ReadFromMqtt' >> ReadFromMqtt(
+              connection_configuration=self._connection_configuration(
+                  source_topic, 'xlang-mqtt-streaming-read'))
+          | 'Passthrough' >> beam.Map(lambda row: beam.Row(bytes=row.bytes)).
+          with_output_types(BYTES_ROW)
+          | 'WriteToMqtt' >> WriteToMqtt(
+              connection_configuration=self._connection_configuration(
+                  sink_topic, 'xlang-mqtt-streaming-write')))
+      result = p.run()
       try:
-        result.cancel()
-      except Exception:  # pylint: disable=broad-except
-        # The unbounded pipeline never finishes on its own; cancellation
-        # after the assertion data was collected is best-effort.
-        logging.warning('Ignoring error while cancelling the pipeline.')
+        # The subscriber exits once NUM_RECORDS messages flowed through the
+        # streaming pipeline (or fails the assertions below on its timeout).
+        subscriber.join(timeout=200)
+      finally:
+        stop_publishing.set()
+        publisher.join()
+        try:
+          result.cancel()
+        except Exception:  # pylint: disable=broad-except
+          # The unbounded pipeline never finishes on its own; cancellation
+          # after the assertion data was collected is best-effort.
+          logging.warning('Ignoring error while cancelling the pipeline.')
+    finally:
+      p._extra_context.__exit__(None, None, None)
 
     self.assertEqual(subscriber_result.get('exit_code'), 0)
     payloads = subscriber_result.get('output', b'').split()
diff --git a/sdks/python/test-suites/direct/common.gradle 
b/sdks/python/test-suites/direct/common.gradle
index 1dd15ecb09f..10bff26afa5 100644
--- a/sdks/python/test-suites/direct/common.gradle
+++ b/sdks/python/test-suites/direct/common.gradle
@@ -438,17 +438,28 @@ project.tasks.register("inferencePostCommitIT") {
 // Create cross-language tasks for running tests against Java expansion 
service(s)
 def gcpProject = project.findProperty('gcpProject') ?: 'apache-beam-testing'
 
+def testDirectRunnerOpts = [
+    "--runner=TestDirectRunner",
+    "--project=${gcpProject}",
+    "--temp_location=gs://temp-storage-for-end-to-end-tests/temp-it",
+]
+
+def prismRunnerOpts = [
+  "--runner=PrismRunner",
+  "--environment_type=LOOPBACK",
+  "--project=${gcpProject}",
+  "--temp_location=gs://temp-storage-for-end-to-end-tests/temp-it",
+]
+
+def usePrismRunnerSuites = ["messagingCrossLanguage"]
+
 project(":sdks:python:test-suites:xlang").ext.xlangTasks.each { taskMetadata ->
     createCrossLanguageUsingJavaExpansionTask(
       name: taskMetadata.name,
       expansionProjectPaths: taskMetadata.expansionProjectPaths,
       collectMarker: taskMetadata.collectMarker,
       numParallelTests: 1,
-      pythonPipelineOptions: [
-        "--runner=TestDirectRunner",
-        "--project=${gcpProject}",
-        "--temp_location=gs://temp-storage-for-end-to-end-tests/temp-it",
-      ],
+      pythonPipelineOptions: usePrismRunnerSuites.contains(taskMetadata.name) 
? prismRunnerOpts : testDirectRunnerOpts,
       pytestOptions: [
         "--capture=no",  // print stdout instantly
         "--timeout=4500", // timeout of whole command execution
diff --git a/sdks/standard_expansion_services.yaml 
b/sdks/standard_expansion_services.yaml
index d0c7f125c44..cb617d3eba1 100644
--- a/sdks/standard_expansion_services.yaml
+++ b/sdks/standard_expansion_services.yaml
@@ -62,6 +62,10 @@
       name: 'WriteToMqtt'
     'beam:schematransform:org.apache.beam:mqtt_read:v1':
       name: 'ReadFromMqtt'
+    'beam:schematransform:org.apache.beam:jms_write:v1':
+      name: 'WriteToJms'
+    'beam:schematransform:org.apache.beam:jms_read:v1':
+      name: 'ReadFromJms'
   skip_transforms:
     # Core SchemaTransforms bundled via :sdks:java:expansion-service; already
     # generated from the Java IO expansion service above.
diff --git a/sdks/standard_external_transforms.yaml 
b/sdks/standard_external_transforms.yaml
index a3f7d5911db..b589448d64b 100644
--- a/sdks/standard_external_transforms.yaml
+++ b/sdks/standard_external_transforms.yaml
@@ -19,7 +19,7 @@
 # configuration in /sdks/standard_expansion_services.yaml.
 # Refer to gen_xlang_wrappers.py for more info.
 #
-# Last updated on: 2026-06-11
+# Last updated on: 2026-07-22
 
 - default_service: sdks:java:io:expansion-service:shadowJar
   description: ''
@@ -214,6 +214,107 @@
     type: str
   identifier: beam:schematransform:org.apache.beam:tfrecord_write:v1
   name: TfrecordWrite
+- default_service: sdks:java:io:messaging-expansion-service:shadowJar
+  description: 'Reads messages from a JMS broker and outputs each message as a 
single
+    `payload` (string) field.
+
+
+    By default the read is unbounded (streaming): it keeps consuming messages 
from
+    the specified queue or topic until the pipeline is stopped. Setting 
`maxNumRecords`
+    and/or `maxReadTimeSeconds` bounds the read, producing a bounded (batch) 
PCollection.'
+  destinations:
+    python: apache_beam/io
+  fields:
+  - description: 'The JMS acknowledge mode: CLIENT_ACKNOWLEDGE, 
CLIENT_ACKNOWLEDGE_UNSAFE,
+      or INDIVIDUAL_ACKNOWLEDGE.'
+    name: acknowledge_mode
+    nullable: true
+    type: str
+  - description: Close timeout for the JMS connection in seconds.
+    name: close_timeout_seconds
+    nullable: true
+    type: int64
+  - description: 'Configuration options to set up the JMS connection.
+
+      Note: if connection factory class name is set, spin up a persistent 
expansion
+      service with the provider client JAR on the classpath:
+
+      java -cp <expansion-service-jar>:<provider-client-jar>
+
+      org.apache.beam.sdk.expansion.service.ExpansionService <port>
+
+      and pass expansion_service=''localhost:<port>'' to the transform. 
Currently,
+      only ActiveMQ (org.apache.activemq.ActiveMQConnectionFactory) is 
embedded into
+      the expansion service'
+    name: connection_configuration
+    nullable: false
+    type: Row(connection_factory_class_name=typing.Optional[str], 
password=typing.Optional[str],
+      server_uri=<class 'str'>, username=typing.Optional[str])
+  - description: The proprietary integer code for individual message 
acknowledgment
+      when using INDIVIDUAL_ACKNOWLEDGE.
+    name: individual_acknowledge_mode_code
+    nullable: true
+    type: int32
+  - description: The max number of records to receive. Setting this will 
result in
+      a bounded PCollection.
+    name: max_num_records
+    nullable: true
+    type: int64
+  - description: The maximum time for this source to read messages. Setting 
this will
+      result in a bounded PCollection.
+    name: max_read_time_seconds
+    nullable: true
+    type: int64
+  - description: The JMS queue to read from. Exclusively one of queue or topic 
must
+      be specified.
+    name: queue
+    nullable: true
+    type: str
+  - description: The JMS topic to read from. Exclusively one of queue or topic 
must
+      be specified.
+    name: topic
+    nullable: true
+    type: str
+  identifier: beam:schematransform:org.apache.beam:jms_read:v1
+  name: ReadFromJms
+- default_service: sdks:java:io:messaging-expansion-service:shadowJar
+  description: 'Publishes messages to a JMS broker. Expects an input 
PCollection of
+    rows with a `payload` (string) or `bytes` field, each of which is 
published as
+    one JMS TextMessage.
+
+
+    Works with both bounded (batch) and unbounded (streaming) input 
PCollections.'
+  destinations:
+    python: apache_beam/io
+  fields:
+  - description: 'Configuration options to set up the JMS connection.
+
+      Note: if connection factory class name is set, spin up a persistent 
expansion
+      service with the provider client JAR on the classpath:
+
+      java -cp <expansion-service-jar>:<provider-client-jar>
+
+      org.apache.beam.sdk.expansion.service.ExpansionService <port>
+
+      and pass expansion_service=''localhost:<port>'' to the transform. 
Currently,
+      only ActiveMQ (org.apache.activemq.ActiveMQConnectionFactory) is 
embedded into
+      the expansion service'
+    name: connection_configuration
+    nullable: false
+    type: Row(connection_factory_class_name=typing.Optional[str], 
password=typing.Optional[str],
+      server_uri=<class 'str'>, username=typing.Optional[str])
+  - description: The JMS queue to write to. Exclusively one of queue or topic 
must
+      be specified.
+    name: queue
+    nullable: true
+    type: str
+  - description: The JMS topic to write to. Exclusively one of queue or topic 
must
+      be specified.
+    name: topic
+    nullable: true
+    type: str
+  identifier: beam:schematransform:org.apache.beam:jms_write:v1
+  name: WriteToJms
 - default_service: sdks:java:io:messaging-expansion-service:shadowJar
   description: 'Reads messages from an MQTT broker and outputs each payload as 
a single
     `bytes` field.

Reply via email to