This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new ba1aaded30 use a per-call DecimalFormat in OggAudioParser duration
output (#2920)
ba1aaded30 is described below
commit ba1aaded3022914d8bff1df31072ace46d6aaf96
Author: Naveed Khan <[email protected]>
AuthorDate: Thu Jul 9 11:07:56 2026 +0000
use a per-call DecimalFormat in OggAudioParser duration output (#2920)
---
.../org/apache/tika/parser/ogg/OggAudioParser.java | 15 ++-
.../parser/ogg/OggAudioParserDurationTest.java | 128 +++++++++++++++++++++
2 files changed, 135 insertions(+), 8 deletions(-)
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
index 31af6758d4..52538307f2 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java
@@ -46,12 +46,6 @@ import org.apache.tika.sax.XHTMLContentHandler;
public abstract class OggAudioParser extends AbstractParser {
private static final long serialVersionUID = 5168743829615945633L;
- private static final DecimalFormat DURATION_FORMAT =
- (DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
- static {
- DURATION_FORMAT.applyPattern("0.0#");
- }
-
protected static void extractChannelInfo(Metadata metadata,
OggAudioInfoHeader info) {
extractChannelInfo(metadata, info.getNumChannels());
}
@@ -133,8 +127,13 @@ public abstract class OggAudioParser extends
AbstractParser {
double duration) throws SAXException {
// Record the duration, if available
if (duration > 0) {
- // Save as metadata to the nearest .01 seconds
- metadata.add(XMPDM.DURATION, DURATION_FORMAT.format(duration));
+ // Save as metadata to the nearest .01 seconds.
+ // DecimalFormat is not thread-safe and these parsers are shared
across
+ // threads, so create a new one per call (see MP4Parser).
+ DecimalFormat durationFormat =
+ (DecimalFormat)
NumberFormat.getNumberInstance(Locale.ROOT);
+ durationFormat.applyPattern("0.0#");
+ metadata.add(XMPDM.DURATION, durationFormat.format(duration));
// Output as Hours / Minutes / Seconds / Parts
String durationStr = formatDuration(duration);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserDurationTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserDurationTest.java
new file mode 100644
index 0000000000..bdcabf157e
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/OggAudioParserDurationTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.tika.parser.ogg;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.XMPDM;
+import org.apache.tika.sax.BodyContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Regression test for the thread safety of duration formatting in
+ * {@link OggAudioParser}. The ogg audio parsers are singletons shared across
+ * threads, so the {@link DecimalFormat} used to emit {@link XMPDM#DURATION}
+ * must not be shared static state. This drives {@code extractDuration}
+ * concurrently with a range of duration shapes and asserts the emitted
+ * metadata always matches the single-threaded value.
+ */
+public class OggAudioParserDurationTest {
+
+ // Durations chosen to exercise rounding / off-fast-path behaviour of the
+ // "0.0#" pattern, where a shared formatter's mutable state would show up.
+ private static final double[] DURATIONS = {
+ 0.1, 1.005, 12.34, 59.995, 123.456, 3599.999, 7200.05, 86399.9
+ };
+
+ @Test
+ @Timeout(60)
+ public void durationFormattingIsThreadSafe() throws Exception {
+ // Expected values computed single-threaded with the same pattern.
+ String[] expected = new String[DURATIONS.length];
+ for (int i = 0; i < DURATIONS.length; i++) {
+ expected[i] = format(DURATIONS[i]);
+ }
+
+ int threads = 32;
+ int iterationsPerThread = 5000;
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ Map<String, String> mismatches = new ConcurrentHashMap<>();
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ List<java.util.concurrent.Future<?>> futures = new ArrayList<>();
+
+ try {
+ for (int t = 0; t < threads; t++) {
+ final int offset = t;
+ futures.add(executor.submit(() -> {
+ try {
+ start.await();
+ for (int i = 0; i < iterationsPerThread; i++) {
+ int idx = (offset + i) % DURATIONS.length;
+ Metadata metadata = new Metadata();
+ XHTMLContentHandler xhtml =
+ new XHTMLContentHandler(new
BodyContentHandler(), metadata);
+ xhtml.startDocument();
+ OggAudioParser.extractDuration(metadata, xhtml,
DURATIONS[idx]);
+ String actual = metadata.get(XMPDM.DURATION);
+ if (!expected[idx].equals(actual)) {
+ mismatches.putIfAbsent(expected[idx],
String.valueOf(actual));
+ }
+ }
+ } catch (Throwable ex) {
+ failure.compareAndSet(null, ex);
+ }
+ }));
+ }
+ start.countDown();
+ executor.shutdown();
+ assertTrue(executor.awaitTermination(50, TimeUnit.SECONDS),
+ "duration formatting did not finish in time");
+ } finally {
+ executor.shutdownNow();
+ }
+
+ if (failure.get() != null) {
+ throw new AssertionError("concurrent duration formatting threw",
failure.get());
+ }
+ assertTrue(mismatches.isEmpty(),
+ "concurrent duration formatting produced corrupted output: " +
mismatches);
+ }
+
+ @Test
+ public void durationOutputMatchesPattern() throws Exception {
+ Metadata metadata = new Metadata();
+ XHTMLContentHandler xhtml = new XHTMLContentHandler(new
BodyContentHandler(), metadata);
+ xhtml.startDocument();
+ OggAudioParser.extractDuration(metadata, xhtml, 123.456);
+ assertEquals(format(123.456), metadata.get(XMPDM.DURATION));
+ }
+
+ private static String format(double duration) {
+ DecimalFormat df = (DecimalFormat)
NumberFormat.getNumberInstance(Locale.ROOT);
+ df.applyPattern("0.0#");
+ return df.format(duration);
+ }
+}