Skip to content

Commit 532af22

Browse files
gmagnuclaude
andauthored
fix(ENGKNOW-3670): don't rebuild gord dictionary through the .gord.link on a cache hit (#131)
* fix(ENGKNOW-3670): don't rebuild gord dictionary through the .gord.link on a cache hit On re-run with an active result cache, GeneralQueryHandler.generateDictionaryFile received the raw `<fingerprint>.gord.link` cache entry. It checked project-root/ thedict.gord (never present) so it always rebuilt, and rebuilt through the unresolved link, writing thedict.gord (and its atomic-write temp sibling) inside the `.gord.link` FILE -> "Not a directory". Resolve the link to the real gord folder first; skip the rebuild when thedict.gord is already there (the normal cache-hit case), and target the resolved folder in the rare rebuild fallback so no path is ever concatenated onto the link file. The now unused fileRoot parameter (and its executeBatch local) is removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ENGKNOW-3670): cover gord-folder cache-hit and cache-miss-overwrite paths White-box (UTestPGorGordFolderDelete.scala): fold in the ENGKNOW-3670 regression alongside the existing take-1 delete-gate suite, reusing its stub-cache session. (1) cache hit with thedict.gord present -> rebuild skipped, no crash, no temp file under the link; (2) fallback with thedict.gord missing -> rebuild targets the resolved folder, never writes through the raw `.gord.link` (asserts the outcome's cause chain names neither ".gord.link" nor "Not a directory", so it fails against the pre-fix code). No end-to-end test can catch this locally: TestUtils.runGorPipe uses LocalFileCacheClient, which re-runs the write when a cached link does not resolve, masking the server-cache crash. End-to-end (UTestGorWriteFolder.java): add testPgorOverwriteGordFolderWith- DifferentDataOnCacheMiss -- overwrite an existing gord folder with disjoint data on a cache miss (different query => different signature => real delete-and-rewrite) and assert only the second write's rows remain (no superset/stale dictionary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc81c83 commit 532af22

3 files changed

Lines changed: 144 additions & 14 deletions

File tree

gortools/src/main/scala/gorsat/QueryHandlers/GeneralQueryHandler.scala

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,25 @@ class GeneralQueryHandler(context: GorContext, header: Boolean) extends GorParal
114114
cmdUpper.startsWith(CommandParseUtilities.GOR_DICTIONARY_FOLDER_PART) || cmdUpper.startsWith(CommandParseUtilities.GOR_DICTIONARY_FOLDER)
115115
}
116116

117-
def generateDictionaryFile(commandToExecute: String, fileRoot: String, fileReader: FileReader, useMd5: Boolean, cacheFile: String): Unit = {
118-
if (isDictionaryFolderMacro(commandToExecute.toUpperCase()) && !fileReader.exists(PathUtils.resolve(fileRoot, GorOptions.DEFAULT_FOLDER_DICTIONARY_NAME))) {
119-
runCommand(context, commandToExecute, cacheFile, useMd5, theTheDict = true)
117+
def generateDictionaryFile(commandToExecute: String, fileReader: FileReader, useMd5: Boolean, cacheFile: String): Unit = {
118+
if (isDictionaryFolderMacro(commandToExecute.toUpperCase())) {
119+
// On a cache hit `cacheFile` may be a `<fingerprint>.gord.link` entry. Resolve it to the real
120+
// gord folder before deciding anything: the old guard checked project-root/thedict.gord so it
121+
// never matched and always rebuilt, and the rebuild ran through the unresolved link
122+
// -> thedict.gord written INSIDE the `.gord.link` file -> "Not a directory" (ENGKNOW-3670).
123+
val resolvedFolder = fileReader.resolveUrl(cacheFile, false).getFullPath()
124+
if (!fileReader.exists(PathUtils.resolve(resolvedFolder, GorOptions.DEFAULT_FOLDER_DICTIONARY_NAME))) {
125+
// Fallback (partial/corrupt cache): rebuild the dictionary, but target the resolved folder,
126+
// never the raw `.gord.link` path.
127+
runCommand(context, commandToExecute, resolvedFolder, useMd5, theTheDict = true)
128+
}
120129
}
121130
}
122131

123132
def executeBatch(commandSignatures: Array[String], commandsToExecute: Array[String], batchGroupNames: Array[String], cacheFiles: Array[String], gorMonitor: GorMonitor): Array[String] = {
124133
val fileNames = new Array[String](commandSignatures.length)
125134
val fileCache = context.getSession.getProjectContext.getFileCache
126135
val fileReader = context.getSession.getProjectContext.getFileReader
127-
val fileRoot = context.getSession.getProjectContext.getProjectRoot
128136
var commandList: List[() => Unit] = Nil
129137
val useMd5 = System.getProperty("gor.caching.md5.enabled", "false").toBoolean
130138

@@ -146,7 +154,7 @@ class GeneralQueryHandler(context: GorContext, header: Boolean) extends GorParal
146154
runAndStoreInCache(nested, fileCache, useMd5)
147155
}
148156
} else {
149-
generateDictionaryFile(commandToExecute, fileRoot, fileReader, useMd5, cacheFile)
157+
generateDictionaryFile(commandToExecute, fileReader, useMd5, cacheFile)
150158
nested.cached(cacheFile)
151159
cacheFile
152160
}

gortools/src/test/java/gorsat/UTestGorWriteFolder.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,31 @@ public void testOverwritePgorWriteGordFolderWithGorrowsMerge() throws IOExceptio
220220
"-cachedir", cachePath.toString());
221221
Assert.assertEquals("pgor write gord should contain chr2 + chr19 rows", 15001L + 10002L, rowCount);
222222
}
223+
224+
// Overwrite an existing gord folder with entirely DIFFERENT data on a cache MISS (the two
225+
// writes use different queries -> different signatures -> the second is a genuine
226+
// delete-and-rewrite, not a skipped cache hit). The folder must be replaced, not appended to:
227+
// if stale parts from the first write survived (a superset dictionary) the counts below break.
228+
@Test
229+
public void testPgorOverwriteGordFolderWithDifferentDataOnCacheMiss() {
230+
var folderpath = workDirPath.resolve("overwrite.gord");
231+
var gorroot = workDirPath.toAbsolutePath().toString();
232+
233+
// First write: 1000 chr1 rows.
234+
TestUtils.runGorPipe("pgor <(gorrows -p chr1:1000-2000) | write " + folderpath,
235+
"-gorroot", gorroot, "-cachedir", cachePath.toString());
236+
237+
// Overwrite with 2000 chr2 rows (disjoint from the first write, different query).
238+
TestUtils.runGorPipe("pgor <(gorrows -p chr2:1000-3000) | write " + folderpath,
239+
"-gorroot", gorroot, "-cachedir", cachePath.toString());
240+
241+
// Only the second write's data may remain: 2000 rows, none on chr1.
242+
long total = TestUtils.runGorPipeCount(new String[]{"gor " + folderpath,
243+
"-gorroot", gorroot, "-cachedir", cachePath.toString()}, false);
244+
Assert.assertEquals("overwrite must replace, not append: only the 2000 chr2 rows", 2000L, total);
245+
246+
long staleChr1 = TestUtils.runGorPipeCount(new String[]{"gor -p chr1 " + folderpath,
247+
"-gorroot", gorroot, "-cachedir", cachePath.toString()}, false);
248+
Assert.assertEquals("no stale chr1 rows from the first write may remain", 0L, staleChr1);
249+
}
223250
}

gortools/src/test/scala/gorsat/Utilities/UTestPGorGordFolderDelete.scala

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,34 @@ import gorsat.QueryHandlers.GeneralQueryHandler
2828
import gorsat.Script.ExecutionBlock
2929
import gorsat.process.{FreemarkerReportBuilder, GenericRunnerFactory, GorSessionCacheManager, SessionBasedQueryEvaluator}
3030
import org.gorpipe.gor.clients.LocalFileCacheClient
31-
import org.gorpipe.gor.model.DriverBackedFileReader
31+
import org.gorpipe.gor.model.{DriverBackedFileReader, GorOptions}
3232
import org.gorpipe.gor.session.{GorSession, ProjectContext, SystemContext}
3333
import org.gorpipe.gor.table.util.PathUtils
3434
import org.junit.Assert
3535
import org.junit.runner.RunWith
3636
import org.scalatest.funsuite.AnyFunSuite
3737
import org.scalatestplus.junit.JUnitRunner
3838

39+
import java.nio.charset.StandardCharsets
3940
import java.nio.file.{Files, Path}
41+
import scala.jdk.CollectionConverters._
4042

41-
// Regression test for ENGKNOW-3656: `pgor ... | write x.gord` deleted the target gord folder
42-
// even when the file cache already held the result and the write was skipped at execution time,
43-
// leaving the skipped write with nothing to build the dictionary from. The fix gates the
44-
// folder-delete in PGor.makeGorDict (and PartGor/Parallel) on the file-cache signature.
43+
// White-box regression tests for the pgor gord-folder cache-hit path. Both bugs here only
44+
// reproduce with a persistent/server file cache: the local LocalFileCacheClient resolves the
45+
// cached link to the folder and re-runs the write, masking them -- so these tests inject a stub
46+
// cache / craft cache links and drive GeneralQueryHandler/PGor directly.
4547
//
46-
// The original crash only reproduces with a persistent/server file cache (the local
47-
// LocalFileCacheClient resolves the cached link to the folder and re-runs the write, masking
48-
// the bug). So this white-box test injects a stub file cache that reports a cache hit for the
49-
// signature and asserts the delete decision directly.
48+
// ENGKNOW-3656: `pgor ... | write x.gord` deleted the target gord folder even when the file cache
49+
// already held the result and the write was skipped at execution time, leaving the skipped write
50+
// with nothing to build the dictionary from. The fix gates the folder-delete in PGor.makeGorDict
51+
// (and PartGor/Parallel) on the file-cache signature. Covered by the makeGorDict tests below.
52+
//
53+
// ENGKNOW-3670: on a cache hit the result cache hands GeneralQueryHandler.generateDictionaryFile a
54+
// raw `<fp>.gord.link` path. Before the fix it checked project-root/thedict.gord (never present) so
55+
// it always rebuilt, and rebuilt through the unresolved link, writing thedict.gord INSIDE the
56+
// `.gord.link` file -> "Not a directory". The fix resolves the link, skips when thedict.gord is
57+
// present, and targets the resolved folder in the fallback. Covered by the generateDictionaryFile
58+
// tests below.
5059
//
5160
// Written as an AnyFunSuite because `:gortools:testScala` discovers tests via
5261
// `org.scalatest.tools.Runner -R build/classes/scala/test`, which only picks up
@@ -144,4 +153,90 @@ class UTestPGorGordFolderDelete extends AnyFunSuite {
144153

145154
Assert.assertFalse("gord folder must be deleted when the result is not cached", survived)
146155
}
156+
157+
// ---- ENGKNOW-3670: generateDictionaryFile must not rebuild through the raw .gord.link ----
158+
159+
/** Create a real gord folder and a `<name>.gord.link` file whose content is that folder's
160+
* absolute path (mimicking a result-cache link entry). Returns the link file path. */
161+
private def makeGordLink(root: Path, folderName: String, withDict: Boolean): Path = {
162+
val folder = Files.createDirectories(root.resolve(folderName))
163+
Files.createFile(folder.resolve("part_chr1.gorz"))
164+
if (withDict) Files.createFile(folder.resolve(GorOptions.DEFAULT_FOLDER_DICTIONARY_NAME))
165+
val link = root.resolve(folderName + ".link")
166+
Files.write(link, folder.toAbsolutePath.toString.getBytes(StandardCharsets.UTF_8))
167+
link
168+
}
169+
170+
/** Absolute paths of every regular file under `root` whose name contains "-temp-". */
171+
private def tempFilesUnder(root: Path): List[Path] =
172+
scala.util.Using.resource(Files.walk(root)) { stream =>
173+
stream.iterator().asScala.filter(Files.isRegularFile(_))
174+
.filter(_.getFileName.toString.contains("-temp-")).toList
175+
}
176+
177+
/** Walk `t`'s cause chain (bounded, to guard against cyclic causes) and concatenate every
178+
* class name + message, so an assertion can inspect the whole chain, not just the wrapper. */
179+
private def fullTrace(t: Throwable): String = {
180+
val sb = new StringBuilder
181+
var cur: Throwable = t
182+
var seen = 0
183+
while (cur != null && seen < 20) {
184+
sb.append(cur.getClass.getName).append(": ").append(cur.getMessage).append('\n')
185+
cur = cur.getCause
186+
seen += 1
187+
}
188+
sb.toString
189+
}
190+
191+
test("generateDictionaryFile skips the rebuild on a cache hit (thedict.gord present): no crash, no temp under the link") {
192+
val root = Files.createTempDirectory("uTestGenDict3670Hit")
193+
root.toFile.deleteOnExit()
194+
val session = sessionWithStubCache(root, cachedFile = null)
195+
val handler = new GeneralQueryHandler(session.getGorContext, false)
196+
val link = makeGordLink(root, "cached.gord", withDict = true)
197+
198+
// Must not throw (pre-fix: GorResourceException "Not a directory" from writing under the link).
199+
handler.generateDictionaryFile(
200+
commandToExecute = "GORDICTFOLDER [x] chr1",
201+
fileReader = session.getProjectContext.getFileReader,
202+
useMd5 = false,
203+
cacheFile = link.toAbsolutePath.toString)
204+
205+
Assert.assertTrue("link file must remain a regular file", Files.isRegularFile(link))
206+
Assert.assertTrue("no -temp- dictionary file may be created", tempFilesUnder(root).isEmpty)
207+
}
208+
209+
test("generateDictionaryFile fallback (thedict.gord missing) never writes under the .gord.link file") {
210+
val root = Files.createTempDirectory("uTestGenDict3670Miss")
211+
root.toFile.deleteOnExit()
212+
val session = sessionWithStubCache(root, cachedFile = null)
213+
val handler = new GeneralQueryHandler(session.getGorContext, false)
214+
val link = makeGordLink(root, "cached.gord", withDict = false)
215+
216+
// The rebuild may fail for unrelated reasons in this minimal session (no real part blocks) --
217+
// that is fine. What must never happen is the ENGKNOW-3670 crash: pre-fix, the rebuild runs
218+
// through the raw `<fp>.gord.link` FILE, failing with a "Not a directory" error whose message /
219+
// cause chain names the `.gord.link` path. Capture the outcome so that crash surfaces.
220+
val outcome = scala.util.Try {
221+
handler.generateDictionaryFile(
222+
commandToExecute = "GORDICTFOLDER [x] chr1",
223+
fileReader = session.getProjectContext.getFileReader,
224+
useMd5 = false,
225+
cacheFile = link.toAbsolutePath.toString)
226+
}
227+
228+
outcome.failed.foreach { t =>
229+
val trace = fullTrace(t)
230+
Assert.assertFalse(
231+
s"rebuild must never attempt to write through the raw .gord.link FILE path, but got:\n$trace",
232+
trace.contains(".gord.link"))
233+
Assert.assertFalse(
234+
s"rebuild must never fail with the ENGKNOW-3670 'Not a directory' crash, but got:\n$trace",
235+
trace.contains("Not a directory"))
236+
}
237+
238+
Assert.assertTrue("link file must remain a regular file", Files.isRegularFile(link))
239+
val underLink = tempFilesUnder(root).filter(_.toString.contains(".gord.link"))
240+
Assert.assertTrue("no dictionary temp file may be created under the .gord.link path", underLink.isEmpty)
241+
}
147242
}

0 commit comments

Comments
 (0)