DorisIOManager.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.doris.paimon;

import org.apache.paimon.disk.BufferFileReader;
import org.apache.paimon.disk.BufferFileWriter;
import org.apache.paimon.disk.FileIOChannel;
import org.apache.paimon.disk.IOManager;
import org.apache.paimon.memory.Buffer;

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.FileChannel;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/** Paimon IOManager adapter which charges temporary I/O to Doris spill management. */
final class DorisIOManager implements IOManager {
    interface SpillAccountant {
        String[] getSpillDirectories() throws IOException;

        void reserve(String path, long bytes) throws IOException;

        void rollback(String path, long bytes);

        void commitWrite(String path, long bytes);

        void recordRead(String path, long bytes);

        void release(String path, long bytes);
    }

    static final class SpillDirectoryCleanupException extends IOException {
        private SpillDirectoryCleanupException(Exception cause) {
            super("Failed to eagerly clean a Paimon spill directory", cause);
        }
    }

    private final SpillAccountant accountant;
    private final Map<String, Long> channelBytes = new ConcurrentHashMap<>();
    private final Map<String, Integer> activeChannelWriters = new ConcurrentHashMap<>();
    private volatile IOManager delegate;

    static DorisIOManager create(long nativeSpillSession) {
        return new DorisIOManager(new NativeSpillAccountant(nativeSpillSession));
    }

    DorisIOManager(SpillAccountant accountant) {
        this(null, accountant);
    }

    DorisIOManager(IOManager delegate, SpillAccountant accountant) {
        this.accountant = accountant;
        this.delegate = delegate;
    }

    private IOManager delegate() throws IOException {
        if (delegate == null) {
            synchronized (this) {
                if (delegate == null) {
                    String[] spillDirectories = accountant.getSpillDirectories();
                    if (spillDirectories == null || spillDirectories.length == 0) {
                        throw new IOException("Doris spill manager returned no available directories");
                    }
                    delegate = IOManager.create(spillDirectories);
                }
            }
        }
        return delegate;
    }

    private IOManager uncheckedDelegate() {
        try {
            return delegate();
        } catch (IOException e) {
            throw new UncheckedIOException("Failed to initialize the Doris spill directory", e);
        }
    }

    @Override
    public FileIOChannel.ID createChannel() {
        return uncheckedDelegate().createChannel();
    }

    @Override
    public FileIOChannel.ID createChannel(String prefix) {
        return uncheckedDelegate().createChannel(prefix);
    }

    @Override
    public String[] tempDirs() {
        return uncheckedDelegate().tempDirs();
    }

    @Override
    public String pickTempDir() {
        return uncheckedDelegate().pickTempDir();
    }

    @Override
    public FileIOChannel.Enumerator createChannelEnumerator() {
        return uncheckedDelegate().createChannelEnumerator();
    }

    @Override
    public BufferFileWriter createBufferFileWriter(FileIOChannel.ID channelID) throws IOException {
        // ExternalBuffer clears old buffer channels with File.delete(), bypassing IOManager
        // deletion. Release those known channels before reserving more space.
        releaseDeletedChannels();
        return new AccountingBufferFileWriter(delegate().createBufferFileWriter(channelID), this);
    }

    @Override
    public BufferFileReader createBufferFileReader(FileIOChannel.ID channelID) throws IOException {
        return new AccountingBufferFileReader(delegate().createBufferFileReader(channelID), this);
    }

    @Override
    public void close() throws Exception {
        IOManager initializedDelegate = delegate;
        if (initializedDelegate == null) {
            return;
        }

        try {
            initializedDelegate.close();
        } catch (Exception e) {
            releaseDeletedChannels();
            throw new SpillDirectoryCleanupException(e);
        }
        releaseDeletedChannels();
    }

    private boolean releaseDeletedChannels() {
        boolean releasedAny = false;
        for (Map.Entry<String, Long> entry : channelBytes.entrySet()) {
            if (!activeChannelWriters.containsKey(entry.getKey())
                    && !new File(entry.getKey()).exists()
                    && channelBytes.remove(entry.getKey(), entry.getValue())) {
                accountant.release(entry.getKey(), entry.getValue());
                releasedAny = true;
            }
        }
        return releasedAny;
    }

    private void reserveWrite(FileIOChannel.ID channelID, long bytes) throws IOException {
        String path = channelID.getPath();
        activeChannelWriters.merge(path, 1, Integer::sum);
        boolean accounted = false;
        boolean tracked = false;
        try {
            try {
                accountant.reserve(path, bytes);
            } catch (IOException reserveFailure) {
                if (!releaseDeletedChannels()) {
                    throw reserveFailure;
                }
                accountant.reserve(path, bytes);
            }
            accounted = true;
            channelBytes.merge(path, bytes, Long::sum);
            tracked = true;
        } finally {
            if (!tracked) {
                if (accounted) {
                    accountant.rollback(path, bytes);
                }
                finishWrite(channelID);
            }
        }
    }

    private void finishWrite(FileIOChannel.ID channelID) {
        activeChannelWriters.computeIfPresent(channelID.getPath(),
                (ignored, writers) -> writers == 1 ? null : writers - 1);
    }

    private void releaseChannel(FileIOChannel.ID channelID) {
        Long released = channelBytes.remove(channelID.getPath());
        if (released != null) {
            accountant.release(channelID.getPath(), released);
        }
    }

    private static final class NativeSpillAccountant implements SpillAccountant {
        private final long nativeSpillSession;

        private NativeSpillAccountant(long nativeSpillSession) {
            this.nativeSpillSession = nativeSpillSession;
        }

        @Override
        public String[] getSpillDirectories() throws IOException {
            return PaimonJniWriter.getPaimonSpillDirectories(nativeSpillSession);
        }

        @Override
        public void reserve(String path, long bytes) throws IOException {
            PaimonJniWriter.reservePaimonSpill(nativeSpillSession, path, bytes);
        }

        @Override
        public void rollback(String path, long bytes) {
            PaimonJniWriter.updatePaimonSpillAccounting(
                    nativeSpillSession, path, -bytes, 0, 0);
        }

        @Override
        public void commitWrite(String path, long bytes) {
            PaimonJniWriter.updatePaimonSpillAccounting(
                    nativeSpillSession, path, 0, bytes, 0);
        }

        @Override
        public void recordRead(String path, long bytes) {
            PaimonJniWriter.updatePaimonSpillAccounting(
                    nativeSpillSession, path, 0, 0, bytes);
        }

        @Override
        public void release(String path, long bytes) {
            PaimonJniWriter.updatePaimonSpillAccounting(
                    nativeSpillSession, path, -bytes, 0, 0);
        }
    }

    private static final class AccountingBufferFileWriter implements BufferFileWriter {
        private final BufferFileWriter delegate;
        private final DorisIOManager manager;

        private AccountingBufferFileWriter(BufferFileWriter delegate, DorisIOManager manager) {
            this.delegate = delegate;
            this.manager = manager;
        }

        @Override
        public void writeBlock(Buffer buffer) throws IOException {
            long bytes = Integer.BYTES + buffer.getSize();
            manager.reserveWrite(getChannelID(), bytes);
            try {
                delegate.writeBlock(buffer);
                manager.accountant.commitWrite(getChannelID().getPath(), bytes);
            } catch (IOException | RuntimeException writeFailure) {
                try {
                    delegate.closeAndDelete();
                } catch (IOException | RuntimeException cleanupFailure) {
                    writeFailure.addSuppressed(cleanupFailure);
                }
                if (!getChannelID().getPathFile().exists()) {
                    manager.releaseChannel(getChannelID());
                }
                throw writeFailure;
            } finally {
                manager.finishWrite(getChannelID());
            }
        }

        @Override
        public FileIOChannel.ID getChannelID() {
            return delegate.getChannelID();
        }

        @Override
        public long getSize() throws IOException {
            return delegate.getSize();
        }

        @Override
        public boolean isClosed() {
            return delegate.isClosed();
        }

        @Override
        public void close() throws IOException {
            delegate.close();
        }

        @Override
        public void deleteChannel() {
            try {
                delegate.deleteChannel();
            } finally {
                if (!getChannelID().getPathFile().exists()) {
                    manager.releaseChannel(getChannelID());
                }
            }
        }

        @Override
        public FileChannel getNioFileChannel() {
            return delegate.getNioFileChannel();
        }

        @Override
        public void closeAndDelete() throws IOException {
            try {
                delegate.closeAndDelete();
            } finally {
                if (!getChannelID().getPathFile().exists()) {
                    manager.releaseChannel(getChannelID());
                }
            }
        }
    }

    private static final class AccountingBufferFileReader implements BufferFileReader {
        private final BufferFileReader delegate;
        private final DorisIOManager manager;

        private AccountingBufferFileReader(BufferFileReader delegate, DorisIOManager manager) {
            this.delegate = delegate;
            this.manager = manager;
        }

        @Override
        public void readInto(Buffer buffer) throws IOException {
            long position = delegate.getNioFileChannel().position();
            delegate.readInto(buffer);
            manager.accountant.recordRead(
                    getChannelID().getPath(), delegate.getNioFileChannel().position() - position);
        }

        @Override
        public boolean hasReachedEndOfFile() {
            return delegate.hasReachedEndOfFile();
        }

        @Override
        public FileIOChannel.ID getChannelID() {
            return delegate.getChannelID();
        }

        @Override
        public long getSize() throws IOException {
            return delegate.getSize();
        }

        @Override
        public boolean isClosed() {
            return delegate.isClosed();
        }

        @Override
        public void close() throws IOException {
            delegate.close();
        }

        @Override
        public void deleteChannel() {
            try {
                delegate.deleteChannel();
            } finally {
                if (!getChannelID().getPathFile().exists()) {
                    manager.releaseChannel(getChannelID());
                }
            }
        }

        @Override
        public FileChannel getNioFileChannel() {
            return delegate.getNioFileChannel();
        }

        @Override
        public void closeAndDelete() throws IOException {
            try {
                delegate.closeAndDelete();
            } finally {
                if (!getChannelID().getPathFile().exists()) {
                    manager.releaseChannel(getChannelID());
                }
            }
        }
    }
}