|
| 1 | +/* |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one or more |
| 3 | + * contributor license agreements. See the NOTICE file distributed with |
| 4 | + * this work for additional information regarding copyright ownership. |
| 5 | + * The ASF licenses this file to You under the Apache License, Version 2.0 |
| 6 | + * (the "License"); you may not use this file except in compliance with |
| 7 | + * the License. You may obtain a copy of the License at |
| 8 | + * |
| 9 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | + * |
| 11 | + * Unless required by applicable law or agreed to in writing, software |
| 12 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | + * See the License for the specific language governing permissions and |
| 15 | + * limitations under the License. |
| 16 | + */ |
| 17 | + |
| 18 | +package org.apache.spark.sql.execution.streaming |
| 19 | + |
| 20 | +import java.io.IOException |
| 21 | +import java.nio.charset.StandardCharsets.UTF_8 |
| 22 | + |
| 23 | +import scala.reflect.ClassTag |
| 24 | + |
| 25 | +import org.apache.hadoop.fs.{Path, PathFilter} |
| 26 | + |
| 27 | +import org.apache.spark.sql.SparkSession |
| 28 | + |
| 29 | +/** |
| 30 | + * An abstract class for compactible metadata logs. It will write one log file for each batch. |
| 31 | + * The first line of the log file is the version number, and there are multiple serialized |
| 32 | + * metadata lines following. |
| 33 | + * |
| 34 | + * As reading from many small files is usually pretty slow, also too many |
| 35 | + * small files in one folder will mess the FS, [[CompactibleFileStreamLog]] will |
| 36 | + * compact log files every 10 batches by default into a big file. When |
| 37 | + * doing a compaction, it will read all old log files and merge them with the new batch. |
| 38 | + */ |
| 39 | +abstract class CompactibleFileStreamLog[T: ClassTag]( |
| 40 | + metadataLogVersion: String, |
| 41 | + sparkSession: SparkSession, |
| 42 | + path: String) |
| 43 | + extends HDFSMetadataLog[Array[T]](sparkSession, path) { |
| 44 | + |
| 45 | + import CompactibleFileStreamLog._ |
| 46 | + |
| 47 | + /** |
| 48 | + * If we delete the old files after compaction at once, there is a race condition in S3: other |
| 49 | + * processes may see the old files are deleted but still cannot see the compaction file using |
| 50 | + * "list". The `allFiles` handles this by looking for the next compaction file directly, however, |
| 51 | + * a live lock may happen if the compaction happens too frequently: one processing keeps deleting |
| 52 | + * old files while another one keeps retrying. Setting a reasonable cleanup delay could avoid it. |
| 53 | + */ |
| 54 | + protected def fileCleanupDelayMs: Long |
| 55 | + |
| 56 | + protected def isDeletingExpiredLog: Boolean |
| 57 | + |
| 58 | + protected def compactInterval: Int |
| 59 | + |
| 60 | + /** |
| 61 | + * Serialize the data into encoded string. |
| 62 | + */ |
| 63 | + protected def serializeData(t: T): String |
| 64 | + |
| 65 | + /** |
| 66 | + * Deserialize the string into data object. |
| 67 | + */ |
| 68 | + protected def deserializeData(encodedString: String): T |
| 69 | + |
| 70 | + /** |
| 71 | + * Filter out the obsolete logs. |
| 72 | + */ |
| 73 | + def compactLogs(logs: Seq[T]): Seq[T] |
| 74 | + |
| 75 | + override def batchIdToPath(batchId: Long): Path = { |
| 76 | + if (isCompactionBatch(batchId, compactInterval)) { |
| 77 | + new Path(metadataPath, s"$batchId$COMPACT_FILE_SUFFIX") |
| 78 | + } else { |
| 79 | + new Path(metadataPath, batchId.toString) |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + override def pathToBatchId(path: Path): Long = { |
| 84 | + getBatchIdFromFileName(path.getName) |
| 85 | + } |
| 86 | + |
| 87 | + override def isBatchFile(path: Path): Boolean = { |
| 88 | + try { |
| 89 | + getBatchIdFromFileName(path.getName) |
| 90 | + true |
| 91 | + } catch { |
| 92 | + case _: NumberFormatException => false |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + override def serialize(logData: Array[T]): Array[Byte] = { |
| 97 | + (metadataLogVersion +: logData.map(serializeData)).mkString("\n").getBytes(UTF_8) |
| 98 | + } |
| 99 | + |
| 100 | + override def deserialize(bytes: Array[Byte]): Array[T] = { |
| 101 | + val lines = new String(bytes, UTF_8).split("\n") |
| 102 | + if (lines.length == 0) { |
| 103 | + throw new IllegalStateException("Incomplete log file") |
| 104 | + } |
| 105 | + val version = lines(0) |
| 106 | + if (version != metadataLogVersion) { |
| 107 | + throw new IllegalStateException(s"Unknown log version: ${version}") |
| 108 | + } |
| 109 | + lines.slice(1, lines.length).map(deserializeData) |
| 110 | + } |
| 111 | + |
| 112 | + override def add(batchId: Long, logs: Array[T]): Boolean = { |
| 113 | + if (isCompactionBatch(batchId, compactInterval)) { |
| 114 | + compact(batchId, logs) |
| 115 | + } else { |
| 116 | + super.add(batchId, logs) |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * Compacts all logs before `batchId` plus the provided `logs`, and writes them into the |
| 122 | + * corresponding `batchId` file. It will delete expired files as well if enabled. |
| 123 | + */ |
| 124 | + private def compact(batchId: Long, logs: Array[T]): Boolean = { |
| 125 | + val validBatches = getValidBatchesBeforeCompactionBatch(batchId, compactInterval) |
| 126 | + val allLogs = validBatches.flatMap(batchId => super.get(batchId)).flatten ++ logs |
| 127 | + if (super.add(batchId, compactLogs(allLogs).toArray)) { |
| 128 | + if (isDeletingExpiredLog) { |
| 129 | + deleteExpiredLog(batchId) |
| 130 | + } |
| 131 | + true |
| 132 | + } else { |
| 133 | + // Return false as there is another writer. |
| 134 | + false |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + /** |
| 139 | + * Returns all files except the deleted ones. |
| 140 | + */ |
| 141 | + def allFiles(): Array[T] = { |
| 142 | + var latestId = getLatest().map(_._1).getOrElse(-1L) |
| 143 | + // There is a race condition when `FileStreamSink` is deleting old files and `StreamFileCatalog` |
| 144 | + // is calling this method. This loop will retry the reading to deal with the |
| 145 | + // race condition. |
| 146 | + while (true) { |
| 147 | + if (latestId >= 0) { |
| 148 | + try { |
| 149 | + val logs = |
| 150 | + getAllValidBatches(latestId, compactInterval).flatMap(id => super.get(id)).flatten |
| 151 | + return compactLogs(logs).toArray |
| 152 | + } catch { |
| 153 | + case e: IOException => |
| 154 | + // Another process using `CompactibleFileStreamLog` may delete the batch files when |
| 155 | + // `StreamFileCatalog` are reading. However, it only happens when a compaction is |
| 156 | + // deleting old files. If so, let's try the next compaction batch and we should find it. |
| 157 | + // Otherwise, this is a real IO issue and we should throw it. |
| 158 | + latestId = nextCompactionBatchId(latestId, compactInterval) |
| 159 | + super.get(latestId).getOrElse { |
| 160 | + throw e |
| 161 | + } |
| 162 | + } |
| 163 | + } else { |
| 164 | + return Array.empty |
| 165 | + } |
| 166 | + } |
| 167 | + Array.empty |
| 168 | + } |
| 169 | + |
| 170 | + /** |
| 171 | + * Since all logs before `compactionBatchId` are compacted and written into the |
| 172 | + * `compactionBatchId` log file, they can be removed. However, due to the eventual consistency of |
| 173 | + * S3, the compaction file may not be seen by other processes at once. So we only delete files |
| 174 | + * created `fileCleanupDelayMs` milliseconds ago. |
| 175 | + */ |
| 176 | + private def deleteExpiredLog(compactionBatchId: Long): Unit = { |
| 177 | + val expiredTime = System.currentTimeMillis() - fileCleanupDelayMs |
| 178 | + fileManager.list(metadataPath, new PathFilter { |
| 179 | + override def accept(path: Path): Boolean = { |
| 180 | + try { |
| 181 | + val batchId = getBatchIdFromFileName(path.getName) |
| 182 | + batchId < compactionBatchId |
| 183 | + } catch { |
| 184 | + case _: NumberFormatException => |
| 185 | + false |
| 186 | + } |
| 187 | + } |
| 188 | + }).foreach { f => |
| 189 | + if (f.getModificationTime <= expiredTime) { |
| 190 | + fileManager.delete(f.getPath) |
| 191 | + } |
| 192 | + } |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +object CompactibleFileStreamLog { |
| 197 | + val COMPACT_FILE_SUFFIX = ".compact" |
| 198 | + |
| 199 | + def getBatchIdFromFileName(fileName: String): Long = { |
| 200 | + fileName.stripSuffix(COMPACT_FILE_SUFFIX).toLong |
| 201 | + } |
| 202 | + |
| 203 | + /** |
| 204 | + * Returns if this is a compaction batch. FileStreamSinkLog will compact old logs every |
| 205 | + * `compactInterval` commits. |
| 206 | + * |
| 207 | + * E.g., if `compactInterval` is 3, then 2, 5, 8, ... are all compaction batches. |
| 208 | + */ |
| 209 | + def isCompactionBatch(batchId: Long, compactInterval: Int): Boolean = { |
| 210 | + (batchId + 1) % compactInterval == 0 |
| 211 | + } |
| 212 | + |
| 213 | + /** |
| 214 | + * Returns all valid batches before the specified `compactionBatchId`. They contain all logs we |
| 215 | + * need to do a new compaction. |
| 216 | + * |
| 217 | + * E.g., if `compactInterval` is 3 and `compactionBatchId` is 5, this method should returns |
| 218 | + * `Seq(2, 3, 4)` (Note: it includes the previous compaction batch 2). |
| 219 | + */ |
| 220 | + def getValidBatchesBeforeCompactionBatch( |
| 221 | + compactionBatchId: Long, |
| 222 | + compactInterval: Int): Seq[Long] = { |
| 223 | + assert(isCompactionBatch(compactionBatchId, compactInterval), |
| 224 | + s"$compactionBatchId is not a compaction batch") |
| 225 | + (math.max(0, compactionBatchId - compactInterval)) until compactionBatchId |
| 226 | + } |
| 227 | + |
| 228 | + /** |
| 229 | + * Returns all necessary logs before `batchId` (inclusive). If `batchId` is a compaction, just |
| 230 | + * return itself. Otherwise, it will find the previous compaction batch and return all batches |
| 231 | + * between it and `batchId`. |
| 232 | + */ |
| 233 | + def getAllValidBatches(batchId: Long, compactInterval: Long): Seq[Long] = { |
| 234 | + assert(batchId >= 0) |
| 235 | + val start = math.max(0, (batchId + 1) / compactInterval * compactInterval - 1) |
| 236 | + start to batchId |
| 237 | + } |
| 238 | + |
| 239 | + /** |
| 240 | + * Returns the next compaction batch id after `batchId`. |
| 241 | + */ |
| 242 | + def nextCompactionBatchId(batchId: Long, compactInterval: Long): Long = { |
| 243 | + (batchId + compactInterval + 1) / compactInterval * compactInterval - 1 |
| 244 | + } |
| 245 | +} |
0 commit comments