OggAudioParserDurationTest.java

/*
 * 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);
    }
}