Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
72604e4
perf(blobExec): Reuse tokenizer workers
EllianCarlos Sep 24, 2026
77e332f
perf(tokenizeByBlobId): Add persistent tokenizer worker
EllianCarlos Sep 25, 2026
e75edff
fix(tokenWorker): Silence perl exec warning on the failure fallback
EllianCarlos Sep 25, 2026
99eb302
ci: Run the tokenizer worker protocol tests
EllianCarlos Sep 25, 2026
d60935a
perf(tokenWorker): Optional per-request trace via BFG_WORKER_TRACE
EllianCarlos Sep 25, 2026
192395f
perf(tokenWorker): Run the srcML chain in-process
EllianCarlos Sep 25, 2026
5e6623c
fix: restore the exec bit on tokenize.pl and tokenBySha.pl
EllianCarlos Sep 28, 2026
4d8cd87
fix(tokenWorker): route .C and .H to C++, as tokenBySha.pl does
EllianCarlos Sep 28, 2026
c439ea4
fix(tests): name the timeout process without exec -a
EllianCarlos Sep 28, 2026
5db0fc1
fix(blobExec): clear worker diagnostics per request
EllianCarlos Oct 4, 2026
360f4d9
fix(tokenWorker): give helper stderr to its request
EllianCarlos Oct 4, 2026
9d67aa0
fix(blobExec): refuse --tokenizer-worker outside pipelines
EllianCarlos Oct 4, 2026
a4b0d8d
refactor(blobExec): take --tokenizer-worker=<path>
EllianCarlos Oct 4, 2026
1b4d214
refactor(blobExec): pass the budget to each worker request
EllianCarlos Oct 4, 2026
4404bd5
refactor(blobExec): reuse ChildRunner's lifetime and kill
EllianCarlos Oct 4, 2026
8c9c563
refactor(blobExec): drop the worker env map
EllianCarlos Oct 4, 2026
e64608c
refactor(blobExec): drop the worker pool shutdown hook
EllianCarlos Oct 4, 2026
16506b5
refactor(tokenWorker): drop the BFG_WORKER_TRACE trace
EllianCarlos Oct 4, 2026
b3a7d4c
test: drop test-only hooks from the worker code
EllianCarlos Oct 4, 2026
79a4786
docs(tokenWorker): bring WORKER_PROTOCOL.md up to date
EllianCarlos Oct 4, 2026
08723d1
refactor(tests): split the token worker driver into checks
EllianCarlos Oct 4, 2026
653743b
style(tokenWorker): drop comments that restate the code
EllianCarlos Oct 4, 2026
d34faca
refactor(tokenWorker): split the long worker subs
EllianCarlos Oct 4, 2026
dd713bc
refactor(blobExec): share the pool's timed read
EllianCarlos Oct 4, 2026
b8d031d
refactor(tokenize): take the DECL lines out of merge
EllianCarlos Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ jobs:
- name: Perl tests (prove)
run: devenv shell -- bash -c 'prove tests/t'

- name: Tokenizer worker protocol tests
run: devenv shell -- bash tests/test_token_worker.sh

- name: Pipeline tests (shell)
run: devenv shell -- bash -c 'tests/tokenize_gate.sh && tests/pipeline_workdir_guard.sh'

Expand Down
9 changes: 7 additions & 2 deletions blobExec/src/main/scala/cregit/blobexec/BlobExec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ object BlobExec {
inserter: ObjectInserter,
timeoutSeconds: Int = DefaultTimeoutSeconds,
onTimeout: () => Unit = () => (),
onParserCrash: () => Unit = () => ()
onParserCrash: () => Unit = () => (),
workerPool: Option[TokenizerWorkerPool] = None
): Outcome = {
val env = Seq("BFG_BLOB" -> origSha, "BFG_FILENAME" -> filename, "BFG_PATH" -> fullPath)

new ChildRunner(timeoutSeconds).run(command, bytes, env) match {
val ran = workerPool match {
case Some(pool) => pool.invoke(bytes, origSha, filename, fullPath, timeoutSeconds)
case None => new ChildRunner(timeoutSeconds).run(command, bytes, env)
}
ran match {
case ChildRunner.Outcome.Killed(why) =>
System.err.println(
s"Warning: command [$command] on blob $origSha at path [$fullPath] gave no usable " +
Expand Down
30 changes: 28 additions & 2 deletions blobExec/src/main/scala/cregit/blobexec/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,16 @@ object Main {
// `raw` (not `s`): the mask example below contains a regex backslash, which a
// processed-escape interpolator rejects. `$$` therefore renders a literal `$`.
private val Usage =
raw"""Usage: blobExec [--abort-on-error] [--pipeline | --pipeline-trees | --shard=K/N] [--warm=<db>] [--blob-timeout=<seconds>] [--stall-timeout=<seconds>] <src.git> <dst.git> <db.sqlite> <command> <fileMaskRegex>
raw"""Usage: blobExec [--abort-on-error] [--pipeline | --pipeline-trees | --shard=K/N] [--warm=<db>] [--tokenizer-worker=<path>] [--blob-timeout=<seconds>] [--stall-timeout=<seconds>] <src.git> <dst.git> <db.sqlite> <command> <fileMaskRegex>
|
| --abort-on-error exit immediately (status 2) on the first non-zero
| exit from <command>, instead of skipping that blob
| --tokenizer-worker=<path>
| keep one protocol-speaking tokenizer process alive per
| available processor and route blob requests through it.
| The worker inherits this process's environment. Needs
| --pipeline or --pipeline-trees; without this flag
| <command> is run once per blob.
| --blob-timeout=<seconds>
| budget for one <command> invocation (default ${BlobExec.DefaultTimeoutSeconds}).
| A child that exceeds it is killed with everything
Expand Down Expand Up @@ -142,6 +148,7 @@ object Main {
var pipelineTrees = false
var shard: Option[(Int, Int)] = None
var warmPath: Option[java.nio.file.Path] = None
var tokenizerWorkerPath: Option[java.nio.file.Path] = None
var blobTimeoutSeconds = BlobExec.DefaultTimeoutSeconds
var stallTimeoutSeconds = Walker.DefaultStallTimeoutSeconds
var stallExplicit = false
Expand Down Expand Up @@ -171,6 +178,8 @@ object Main {
sys.exit(1)
}
warmPath = Some(p)
case w if w.startsWith("--tokenizer-worker=") =>
tokenizerWorkerPath = Some(Paths.get(w.stripPrefix("--tokenizer-worker=")))
case t if t.startsWith("--blob-timeout=") =>
val spec = t.stripPrefix("--blob-timeout=")
parsePositiveSeconds(spec) match {
Expand Down Expand Up @@ -238,6 +247,16 @@ object Main {
System.err.println(s"Error: command [$command] does not exist")
sys.exit(1)
}
tokenizerWorkerPath.foreach { path =>
if (!Files.isRegularFile(path) || !Files.isExecutable(path)) {
System.err.println(s"Error: tokenizer worker [$path] is not an executable file")
sys.exit(1)
}
if (!(pipeline || pipelineTrees)) {
System.err.println("Error: --tokenizer-worker needs --pipeline or --pipeline-trees")
sys.exit(1)
}
}
Comment on lines +250 to +259

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Spec, medium] The pool starts in modes that never use it.

Only Walker.executeBlobTask calls invoke. Only walkCommitsPipelined and walkCommitsTrees call executeBlobTask. The serial walker and --shard=K/N go through resolveMisses, which calls BlobExec.run without the pool. But main starts the pool in every mode when the flag is set (line 309). So run_pipeline_process.sh --mode serial --tokenizer-worker starts one worker per CPU, each with its own ctags and srcml2token, and then still spawns tokenBySha.pl for each blob.

The PR body says these modes ignore the flag. They do not ignore it: they start idle processes. Reject the flag outside the pipeline modes (--shard already rejects --pipeline, so this check covers it). Cost: +4 lines.

Suggested change
tokenizerWorkerPath.foreach { path =>
if (!Files.isRegularFile(path) || !Files.isExecutable(path)) {
System.err.println(s"Error: tokenizer worker [$path] is not an executable file")
sys.exit(1)
}
}
tokenizerWorkerPath.foreach { path =>
if (!Files.isRegularFile(path) || !Files.isExecutable(path)) {
System.err.println(s"Error: tokenizer worker [$path] is not an executable file")
sys.exit(1)
}
if (!(pipeline || pipelineTrees)) {
System.err.println("Error: --tokenizer-worker needs --pipeline or --pipeline-trees")
sys.exit(1)
}
}

if (mask.isEmpty) {
System.err.println("Error: fileMaskRegex must be non-empty")
sys.exit(1)
Expand All @@ -258,7 +277,8 @@ object Main {
s"abortOnError=$abortOnError pipeline=$pipeline pipelineTrees=$pipelineTrees " +
s"shard=$shardStr warm=$warmStr blobTimeout=${blobTimeoutSeconds}s " +
s"stallTimeout=${stallTimeoutSeconds}s incremental=$incremental " +
s"denylistEntries=${denylist.size}"
s"denylistEntries=${denylist.size} " +
s"tokenizerWorker=${tokenizerWorkerPath.map(_.toString).getOrElse("off")}"
)

val src: FileRepository = openSrc(srcPath)
Expand All @@ -272,14 +292,19 @@ object Main {

// The cumulative figure is forensic only: gating on it would make one past
// timeout permanent.
var workerPool: Option[TokenizerWorkerPool] = None
val (stats, timedOutEver) = try {
val parallelism = math.max(1, Runtime.getRuntime.availableProcessors)
workerPool = tokenizerWorkerPath.map { path =>
new TokenizerWorkerPool(Seq(path.toString), parallelism)
}
val walker = new Walker(
src, dst, mapping, mask.r, command, abortOnError, parallelism,
pipeline, pipelineTrees, shard,
destinationMayContainObjects = incremental,
blobTimeoutSeconds = blobTimeoutSeconds,
stallTimeoutSeconds = stallTimeoutSeconds,
workerPool = workerPool,
denylist = denylist
)
val s = walker.run()
Expand All @@ -288,6 +313,7 @@ object Main {
if (s.blobsTimedOut > 0) mapping.setMeta(BlobsTimedOutMetaKey, total.toString)
(s, total)
} finally {
workerPool.foreach(_.close())
mapping.close()
dst.close()
src.close()
Expand Down
258 changes: 258 additions & 0 deletions blobExec/src/main/scala/cregit/blobexec/TokenizerWorkerPool.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package cregit.blobexec

import java.io.{BufferedInputStream, BufferedOutputStream, BufferedReader, EOFException, IOException, InputStream, InputStreamReader}
import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
import java.util.concurrent.{ArrayBlockingQueue, ConcurrentHashMap, CountDownLatch, TimeUnit}
import scala.jdk.CollectionConverters._

final class TokenizerWorkerPool(
workerCommand: Seq[String],
size: Int
) extends AutoCloseable {

require(workerCommand.nonEmpty, "workerCommand must not be empty")
require(size > 0, "worker pool size must be positive")

private val WorkerTimeoutExitCode = 124
private val ReadySeconds = 30L

private final class Response(val exitCode: Int, val stdout: Array[Byte], val stderr: String)

private final class Worker(
val index: Int,
val process: Process,
val stdin: BufferedOutputStream,
val stdout: BufferedInputStream
) {
private val diagnostics = new StringBuilder

def appendDiagnostic(line: String): Unit = diagnostics.synchronized {
diagnostics.append(line).append('\n')
}

def takeDiagnostics(): String = diagnostics.synchronized {
try diagnostics.toString finally diagnostics.clear()
}
}

private val idle = new ArrayBlockingQueue[Worker](size)
private val workers = ConcurrentHashMap.newKeySet[Worker]()
private val closed = new AtomicBoolean(false)

try {
(0 until size).foreach { index =>
val worker = startWorker(index)
workers.add(worker)
idle.put(worker)
}
} catch {
case failure: Throwable =>
close()
throw failure
}

def invoke(
bytes: Array[Byte],
origSha: String,
filename: String,
fullPath: String,
timeoutSeconds: Int
): ChildRunner.Outcome = {
require(timeoutSeconds > 0, "worker timeout must be positive")
if (closed.get()) throw new IllegalStateException("tokenizer worker pool is closed")

val worker = idle.take()
worker.takeDiagnostics()
try writeRequest(worker, bytes, origSha, filename, fullPath, timeoutSeconds)
catch { case failure: IOException => return failedWorker(worker, failure) }

val backstopSeconds = ChildRunner.maxLifetimeSeconds(timeoutSeconds)
readWithin(s"token-worker-response-${worker.index}", backstopSeconds)(readResponse(worker.stdout)) match {
case None =>
replaceWorker(worker)
ChildRunner.Outcome.Killed(s"no response within ${backstopSeconds}s; worker replaced")
case Some(Left(failure)) =>
failedWorker(worker, failure)
case Some(Right(result)) =>
returnWorker(worker)
if (result.exitCode == WorkerTimeoutExitCode)
ChildRunner.Outcome.Killed(s"no exit within ${timeoutSeconds}s")
else ChildRunner.Outcome.Exited(result.exitCode, result.stdout, result.stderr)
}
}

override def close(): Unit = {
if (closed.compareAndSet(false, true)) {
val snapshot = workers.asScala.toVector
snapshot.foreach(worker => try worker.stdin.close() catch { case _: IOException => () })
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2)
snapshot.foreach { worker =>
val remaining = deadline - System.nanoTime()
if (remaining > 0) worker.process.waitFor(remaining, TimeUnit.NANOSECONDS)
}
snapshot.filter(_.process.isAlive).foreach(_.process.destroyForcibly())
snapshot.foreach(_.process.waitFor(2, TimeUnit.SECONDS))
workers.clear()
idle.clear()
}
}

private def startWorker(index: Int): Worker = {
val builder = new ProcessBuilder(workerCommand: _*)
val process = builder.start()
val worker = new Worker(
index,
process,
new BufferedOutputStream(process.getOutputStream),
new BufferedInputStream(process.getInputStream)
)
startStderrForwarder(worker)

readWithin(s"token-worker-ready-$index", ReadySeconds)(readHeader(worker.stdout)) match {
case Some(Right("READY")) => worker
case notReady =>
process.destroyForcibly()
throw startFailure(index, notReady)
}
}

private def startFailure(index: Int, ready: Option[Either[Throwable, String]]): IllegalStateException =
ready match {
case None =>
new IllegalStateException(s"tokenizer worker $index did not print READY within $ReadySeconds seconds")
case Some(Right(line)) =>
new IllegalStateException(s"tokenizer worker $index printed [$line] instead of READY")
case Some(Left(failure)) =>
new IllegalStateException(s"tokenizer worker $index failed before READY", failure)
}

private def readWithin[A](threadName: String, seconds: Long)(read: => A): Option[Either[Throwable, A]] = {
val result = new AtomicReference[Either[Throwable, A]]()
val done = new CountDownLatch(1)
val reader = new Thread(
() => {
try result.set(Right(read))
catch { case failure: Throwable => result.set(Left(failure)) }
finally done.countDown()
},
threadName
)
reader.setDaemon(true)
reader.start()
if (done.await(seconds, TimeUnit.SECONDS)) Some(result.get()) else None
}

private def startStderrForwarder(worker: Worker): Unit = {
def forward(line: String): Unit = {
worker.appendDiagnostic(line)
System.err.println(s"tokenWorker[${worker.index}]: $line")
}
val thread = new Thread(
() => {
val reader = new BufferedReader(new InputStreamReader(worker.process.getErrorStream, UTF_8))
try Iterator.continually(reader.readLine()).takeWhile(_ != null).foreach(forward)
catch {
case failure: IOException if worker.process.isAlive =>
forward(s"stderr forwarding failed: ${failure.getMessage}")
case _: IOException => ()
} finally try reader.close() catch { case _: IOException => () }
},
s"token-worker-stderr-${worker.index}"
)
thread.setDaemon(true)
thread.start()
}

private def writeRequest(
worker: Worker,
bytes: Array[Byte],
origSha: String,
filename: String,
fullPath: String,
timeoutSeconds: Int
): Unit = {
val filenameBytes = filename.getBytes(UTF_8)
val pathBytes = fullPath.getBytes(UTF_8)
val header = s"REQ $origSha ${bytes.length} ${filenameBytes.length} ${pathBytes.length} $timeoutSeconds\n"
.getBytes(UTF_8)
worker.stdin.write(header)
worker.stdin.write(filenameBytes)
worker.stdin.write(pathBytes)
worker.stdin.write(bytes)
worker.stdin.flush()
}

private def readResponse(input: InputStream): Response = {
val fields = readHeader(input).split(" ", -1)
if (fields.length != 4 || fields(0) != "RES")
throw new IOException(s"malformed tokenizer worker response header [${fields.mkString(" ")}]")

val exitCode = parseInt(fields(1), "exit")
val stdoutLength = parseLength(fields(2), "outLen")
val stderrLength = parseLength(fields(3), "errLen")
val stdout = readExactly(input, stdoutLength)
val stderr = new String(readExactly(input, stderrLength), UTF_8)
new Response(exitCode, stdout, stderr)
}

private def readHeader(input: InputStream): String = {
val bytes = new java.io.ByteArrayOutputStream
var next = input.read()
while (next != -1 && next != '\n') {
if (bytes.size() >= 8192) throw new IOException("tokenizer worker response header exceeds 8192 bytes")
bytes.write(next)
next = input.read()
}
if (next == -1) throw new EOFException("tokenizer worker closed stdout")
new String(bytes.toByteArray, UTF_8)
}

private def readExactly(input: InputStream, length: Int): Array[Byte] = {
val bytes = new Array[Byte](length)
var offset = 0
while (offset < length) {
val read = input.read(bytes, offset, length - offset)
if (read == -1) throw new EOFException(s"tokenizer worker closed stdout after $offset of $length bytes")
offset += read
}
bytes
}

private def parseInt(value: String, name: String): Int =
value.toIntOption.getOrElse(throw new IOException(s"invalid $name [$value] in tokenizer response"))

private def parseLength(value: String, name: String): Int = {
val length = parseInt(value, name)
if (length < 0) throw new IOException(s"negative $name [$value] in tokenizer response")
length
}

private def failedWorker(worker: Worker, failure: Throwable): ChildRunner.Outcome = {
val workerExit = if (worker.process.waitFor(100, TimeUnit.MILLISECONDS)) Some(worker.process.exitValue()) else None
val diagnostics = worker.takeDiagnostics()
replaceWorker(worker)
if (workerExit.contains(WorkerTimeoutExitCode))
ChildRunner.Outcome.Killed(s"worker exited $WorkerTimeoutExitCode (timeout)")
else {
val stderr = if (diagnostics.nonEmpty) diagnostics else Option(failure.getMessage).getOrElse(failure.toString)
ChildRunner.Outcome.Exited(workerExit.filter(_ != 0).getOrElse(1), Array.emptyByteArray, stderr)
}
}

private def returnWorker(worker: Worker): Unit = {
if (worker.process.isAlive && !closed.get()) idle.put(worker)
else replaceWorker(worker)
}

private def replaceWorker(worker: Worker): Unit = {
workers.remove(worker)
try worker.stdin.close() catch { case _: IOException => () }
if (worker.process.isAlive) ChildRunner.killTree(worker.process)
if (!closed.get()) {
val replacement = startWorker(worker.index)
workers.add(replacement)
idle.put(replacement)
}
}
}
4 changes: 3 additions & 1 deletion blobExec/src/main/scala/cregit/blobexec/Walker.scala
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ final class Walker(
destinationMayContainObjects: Boolean = true,
blobTimeoutSeconds: Int = BlobExec.DefaultTimeoutSeconds,
stallTimeoutSeconds: Int = Walker.DefaultStallTimeoutSeconds,
workerPool: Option[TokenizerWorkerPool] = None,
denylist: BlobDenylist = BlobDenylist.shipped
) {
import Walker._
Expand Down Expand Up @@ -852,7 +853,8 @@ final class Walker(
inserter = workerInserter,
timeoutSeconds = blobTimeoutSeconds,
onTimeout = () => { blobsTimedOut.increment(); unusable.set(true) },
onParserCrash = () => { blobsParserCrashed.increment(); unusable.set(true) }
onParserCrash = () => { blobsParserCrashed.increment(); unusable.set(true) },
workerPool = workerPool
)
val res = outcome match {
case BlobExec.Outcome.Skip if unusable.get() =>
Expand Down
Loading
Loading