Skip to content

Commit 8d8e233

Browse files
jerryshaozsxwing
authored andcommitted
[SPARK-15698][SQL][STREAMING] Add the ability to remove the old MetadataLog in FileStreamSource (branch-2.0)
## What changes were proposed in this pull request? Backport #13513 to branch 2.0. ## How was this patch tested? Jenkins Author: jerryshao <sshao@hortonworks.com> Closes #15163 from zsxwing/SPARK-15698-spark-2.0.
1 parent 2bd37ce commit 8d8e233

9 files changed

Lines changed: 551 additions & 222 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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+
}

sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/FileStreamSink.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ class FileStreamSink(
5656

5757
private val basePath = new Path(path)
5858
private val logPath = new Path(basePath, FileStreamSink.metadataDir)
59-
private val fileLog = new FileStreamSinkLog(sparkSession, logPath.toUri.toString)
59+
private val fileLog =
60+
new FileStreamSinkLog(FileStreamSinkLog.VERSION, sparkSession, logPath.toUri.toString)
6061
private val hadoopConf = sparkSession.sessionState.newHadoopConf()
6162
private val fs = basePath.getFileSystem(hadoopConf)
6263

0 commit comments

Comments
 (0)