Skip to content

Commit 7a331a0

Browse files
authored
Only set sqlite WAL on file creation to allow for concurrent runs on Windows (#1402)
1 parent b18a314 commit 7a331a0

9 files changed

Lines changed: 62 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
Bugfixes:
11+
* Fix flaky `SQLITE_IOERR_TRUNCATE` on Windows when multiple spago processes connect concurrently to the cache DB, by skipping `PRAGMA journal_mode = WAL` when it's already enabled (WAL mode is persistent in the DB file header) and tolerating the race on the initial set
12+
* Retry transient network failures (connection errors and 5xx responses) when fetching package tarballs and calling the registry API, instead of failing immediately
13+
1014
## [1.0.4] - 2026-03-30
1115

1216
Bugfixes:

src/Spago/Command/Fetch.purs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,9 +319,13 @@ fetchPackagesToLocalCache packages = do
319319
, url = packageUrl
320320
}
321321
)
322-
-- If we get a 503, we want the backoff to kick in, so we wait here and we'll eventually be retried
322+
-- If the request failed (connection error) or got a 5xx, we want the backoff
323+
-- to kick in. withBackoff' only retries on its own timeout, so we delay here
324+
-- to lose the race against runTimeout and trigger a retry.
323325
case res of
324-
Right { status } | status == StatusCode 503 -> Aff.delay (Aff.Milliseconds 30_000.0)
326+
Left _ -> Aff.delay (Aff.Milliseconds 30_000.0)
327+
Right { status } | status >= StatusCode 500 && status < StatusCode 600 ->
328+
Aff.delay (Aff.Milliseconds 30_000.0)
325329
_ -> pure unit
326330
pure res
327331
case response of

src/Spago/Db.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,28 @@ export const connectImpl = (databasePath, logger) => {
1111

1212
const db = new DatabaseSync(databasePath, {
1313
enableForeignKeyConstraints: true,
14-
timeout: 5000, // Wait up to 5s if database is locked (matches better-sqlite3 default)
14+
timeout: 5000, // Wait up to 5s if database is locked
1515
});
1616

17-
db.exec("PRAGMA journal_mode = WAL");
17+
// WAL journal mode is persistent in the DB file header (bytes 18-19), so
18+
// once set it sticks across connections and reopens. We skip the PRAGMA when
19+
// it's already set to avoid hitting winTruncate on the wal-index (.shm),
20+
// which races between concurrent spago processes on Windows and surfaces as
21+
// SQLITE_IOERR_TRUNCATE (errcode 1546).
22+
//
23+
// When two fresh processes race the initial set, the loser's exec throws,
24+
// but the winner has already written WAL to the header — so we only re-throw
25+
// if WAL didn't actually end up enabled (i.e. the error wasn't the benign
26+
// race we expect).
27+
//
28+
// See:
29+
// https://sqlite.org/pragma.html (journal_mode persistence)
30+
// https://sqlite.org/fileformat.html (header bytes 18-19 = WAL marker)
31+
const inWal = () => db.prepare("PRAGMA journal_mode").get()?.journal_mode === "wal";
32+
if (!inWal()) {
33+
try { db.exec("PRAGMA journal_mode = WAL"); }
34+
catch (e) { if (!inWal()) throw e; }
35+
}
1836

1937
db.prepare(`CREATE TABLE IF NOT EXISTS package_sets
2038
( version TEXT PRIMARY KEY NOT NULL

src/Spago/Prelude.purs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ withBackoff { delay: Aff.Milliseconds timeout, action, shouldCancel, shouldRetry
162162
case maybeRetry of
163163
Maybe.Nothing -> pure Maybe.Nothing
164164
Maybe.Just newAction -> do
165-
let newTimeout = Int.floor timeout `Int.pow` (attempt + 1)
165+
let newTimeout = Int.floor timeout * (2 `Int.pow` attempt)
166166
maybeResult <- runAction attempt newAction newTimeout
167167
loop (attempt + 1) maybeResult
168168
Maybe.Just result ->

src/Spago/Registry.purs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -431,13 +431,20 @@ submitRegistryOperation payload = do
431431
callRegistry :: forall env a b. String -> CJ.Codec b -> Maybe { codec :: CJ.Codec a, data :: a } -> Spago (GitEnv env) b
432432
callRegistry url outputCodec maybeInput = handleError do
433433
logDebug $ "Calling registry at " <> url
434-
response <- liftAff $ withBackoff' $ try case maybeInput of
435-
Just { codec: inputCodec, data: input } -> Http.fetch url
436-
{ method: Http.POST
437-
, headers: { "Content-Type": "application/json" }
438-
, body: Json.stringifyJson inputCodec input
439-
}
440-
Nothing -> Http.fetch url { method: Http.GET }
434+
response <- liftAff $ withBackoff' do
435+
res <- try case maybeInput of
436+
Just { codec: inputCodec, data: input } -> Http.fetch url
437+
{ method: Http.POST
438+
, headers: { "Content-Type": "application/json" }
439+
, body: Json.stringifyJson inputCodec input
440+
}
441+
Nothing -> Http.fetch url { method: Http.GET }
442+
case res of
443+
Left _ -> Aff.delay (Aff.Milliseconds 30_000.0)
444+
Right { status } | status >= 500 && status < 600 ->
445+
Aff.delay (Aff.Milliseconds 30_000.0)
446+
_ -> pure unit
447+
pure res
441448
case response of
442449
Nothing -> pure $ Left $ "Could not reach the registry at " <> url
443450
Just (Left err) -> pure $ Left $ "Error while calling the registry:\n " <> Exception.message err

test-fixtures/build/1148-warnings-diff-errors/errors/expected-stderr.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Reading Spago workspace configuration...
44

55
Downloading dependencies...
66
Building...
7-
[1 of 2] Compiling Foo
8-
[2 of 2] Compiling Main
7+
[x of 2] Compiling module-name
8+
[x of 2] Compiling module-name
99
[ERROR 1/2 TypesDoNotUnify] src/Foo.purs:4:5
1010

1111
4 x = "nope"

test/Prelude.purs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import Data.Array as Array
99
import Data.Map as Map
1010
import Data.String (Pattern(..), Replacement(..))
1111
import Data.String as String
12+
import Data.String.Regex as Regex
13+
import Data.String.Regex.Flags as RF
1214
import Effect.Aff as Aff
1315
import Effect.Aff.AVar (AVar)
1416
import Effect.Aff.AVar as AVar
@@ -176,6 +178,17 @@ sanitizePlatformOutput =
176178
>>> String.replaceAll (Pattern "\\") (Replacement "/")
177179
>>> String.replaceAll (Pattern "\r\n") (Replacement "\n")
178180

181+
-- | Normalize `[N of <total>] Compiling <module>` lines. purs schedules
182+
-- | independent modules in whatever order system resources allow, so fixture
183+
-- | comparison has to ignore the order. Pass the total module count expected.
184+
normalizeCompileOrder :: Int -> String -> String
185+
normalizeCompileOrder total =
186+
Regex.replace regex ("[x of " <> show total <> "] Compiling module-name")
187+
where
188+
regex = unsafeFromRight $ Regex.regex
189+
("\\[\\d+ of " <> show total <> "\\] Compiling [^\n]+")
190+
RF.global
191+
179192
checkFixture :: path. IsPath path => path -> FixturePath -> Aff Unit
180193
checkFixture filepath fixturePath = checkFixture' filepath fixturePath identity (shouldEqualStr `on` String.trim)
181194

test/Spago/Build.purs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ spec sem = Spec.parallel $ Spec.around (withBuildLock sem) do
212212
{ stdoutFile: Nothing
213213
, stderrFile: Just $ fixture expectedFixture
214214
, result
215-
, sanitize: sanitizePlatformOutput
215+
, sanitize: sanitizePlatformOutput >>> normalizeCompileOrder 2
216216
}
217217

218218
FS.copyTree { src: fixture "build/1148-warnings-diff-errors", dst: testCwd </> "." }

test/Spago/Publish.purs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ module Test.Spago.Publish
55

66
import Test.Prelude
77

8-
import Data.String.Regex as Regex
9-
import Data.String.Regex.Flags as RF
108
import Node.Platform as Platform
119
import Node.Process as Process
1210
import Spago.FS as FS
@@ -94,17 +92,9 @@ spec = Spec.around withTempDir do
9492
{ stdoutFile: Nothing
9593
, stderrFile: Just file
9694
, result: isLeft
97-
, sanitize: sanitizePlatformOutput >>> Regex.replace buildOrderRegex "[x of 3] Compiling module-name"
95+
, sanitize: sanitizePlatformOutput >>> normalizeCompileOrder 3
9896
}
9997

100-
-- We have to ignore lines like "[1 of 3] Compiling Effect.Console" when
101-
-- comparing output, because the compiler will always compile in
102-
-- different order, depending on how the system resources happened to
103-
-- align at the moment of the test run.
104-
buildOrderRegex = unsafeFromRight $ Regex.regex
105-
"\\[\\d of 3\\] Compiling (Effect\\.Console|Effect\\.Class\\.Console|Lib)"
106-
RF.global
107-
10898
FS.copyTree { src: fixture "publish/1110-solver-different-version", dst: testCwd }
10999
spago [ "build" ] >>= shouldBeSuccess
110100
doTheGitThing testCwd

0 commit comments

Comments
 (0)