Skip to content

Commit 45e43e3

Browse files
committed
crawler: seed root, log start accession, single-transaction bulk insert
Fixes surfaced by large-scale crawls (e.g. ENA umbrella projects like PRJNA9506 that fan out to thousands of child projects): - Discovery is run-driven, so a project/study with no runs discovered nothing — not even itself — and persisted an empty database. Discovery.withRoot seeds the root into the bucket its prefix implies (PRJ... -> BioProject, SRP/ERP/DRP... -> Study) so the root record always persists. - Add a Started crawl event emitted before discovery, so the first log line identifies the root accession instead of only learning it at 'done'. - Make Sql.withTransaction reentrant (a nested call joins the active transaction rather than throwing — SQLite has no nested transactions) and wrap the crawler's whole insert pass in one transaction, collapsing one commit per record into a single commit. disableForeignKeys stays outside it. Adds regression tests for root seeding, childless-project persistence, Started-first ordering, and the reentrant-transaction contract.
1 parent 812f175 commit 45e43e3

6 files changed

Lines changed: 181 additions & 11 deletions

File tree

‎RELEASE_NOTES.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
### 0.2.1 (2026-07-09)
2+
3+
Crawler fixes surfaced by large-scale crawls (incl. ENA umbrella projects such as `PRJNA9506`, which fans out to thousands of child projects).
4+
5+
- **BioFSharp.INSDC.Crawler** — a project/study with no sequencing runs now persists its own record: discovery is run-driven, so a childless root previously discovered nothing (not even itself). `Discovery.withRoot` seeds the root into the bucket its accession prefix implies (`PRJ…` → BioProject, `SRP`/`ERP`/`DRP…` → Study).
6+
- **BioFSharp.INSDC.Crawler** — new `Started` crawl event, emitted before discovery, so the first log line identifies the root accession rather than only learning it at `done —`.
7+
- **BioFSharp.INSDC.SQLite / Crawler** — bulk insert: `Sql.withTransaction` is now reentrant (a nested call joins the active transaction rather than throwing, since SQLite has no nested transactions), letting the crawler persist an entire crawl in a single transaction instead of one commit per record — the difference between a trickle and a bulk load on crawls of hundreds of thousands of runs.
8+
19
### 0.2.0 (2026-07-09)
210

311
Expands the suite from two packages to five: `BioFSharp.FileFormats.INSDC` and

‎src/BioFSharp.INSDC.Crawler/Crawler.fs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ module private Fetch =
8989
/// carries the relationships persistence needs to thread foreign keys).
9090
let crawlCore (options: CrawlOptions) (rootAccession: string) : Async<CrawlResult * DiscoveredSet> =
9191
async {
92+
options.Log(Started rootAccession)
9293
let! discovered = Discovery.discoverAsync options rootAccession
9394

9495
options.Log(
@@ -128,6 +129,11 @@ module private Persist =
128129
open Microsoft.Data.Sqlite
129130
open BioFSharp.INSDC.SQLite
130131

132+
// Just the transaction helper — a module abbreviation rather than `open
133+
// ...Internal`, whose `Schema` would shadow the public `Schema.init` used
134+
// below.
135+
module Sql = BioFSharp.INSDC.SQLite.Internal.Sql
136+
131137
/// Opens (creating the file if needed) a SQLite connection for the crawl.
132138
/// Foreign-key enforcement is turned OFF for the insert pass in `persist`
133139
/// (the bundled schema's `PRAGMA foreign_keys = ON` would otherwise reject
@@ -199,6 +205,14 @@ module private Persist =
199205
// -> run) is guaranteed by the insertion order below instead.
200206
disableForeignKeys connection
201207

208+
// One surrounding transaction for the whole insert pass. Each entity
209+
// `insert` opens its own `Sql.withTransaction`, but that is reentrant
210+
// and joins this one, so the ~half-million per-record commits collapse
211+
// into a single commit at the end — the difference between a trickle and
212+
// a bulk load. `disableForeignKeys` stays *outside* it: `PRAGMA
213+
// foreign_keys` is a no-op while a transaction is open.
214+
Sql.withTransaction connection (fun _tx ->
215+
202216
let hashSet (accessions: string seq) =
203217
System.Collections.Generic.HashSet<string>(accessions)
204218

@@ -288,7 +302,7 @@ module private Persist =
288302
rIns <- rIns + 1
289303
| _ -> options.Log(Failed(sprintf "persist Run %s" run.Accession, "no stored parent experiment"))
290304

291-
options.Log(Persisted("Run", rIns, rSkip))
305+
options.Log(Persisted("Run", rIns, rSkip)))
292306

293307
/// The public crawler API. Two surfaces: return the connected records, or
294308
/// persist them (and the connectivity relations) into a SQLite database.

‎src/BioFSharp.INSDC.Crawler/Discovery.fs‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,33 @@ module Discovery =
164164
RunToExperiment = mapOf (fun r -> r.RunAccession) (fun r -> r.ExperimentAccession)
165165
}
166166

167+
/// Ensures `rootAccession` is present in the discovered set even when the
168+
/// `read_run` report never named it. Discovery is entirely run-driven, so a
169+
/// project or study with no runs yields a header-only report and an empty
170+
/// set — without this the root's own record would never be fetched or
171+
/// stored. The root is added to the bucket its accession prefix implies
172+
/// (`PRJ...` -> BioProject, `SRP`/`ERP`/`DRP...` -> Study); an unrecognized
173+
/// prefix (or one already present) leaves the set untouched.
174+
let withRoot (rootAccession: string) (set: DiscoveredSet) : DiscoveredSet =
175+
let hasPrefix (prefixes: string list) =
176+
prefixes
177+
|> List.exists (fun p -> rootAccession.StartsWith(p, StringComparison.OrdinalIgnoreCase))
178+
179+
let addDistinct (xs: string list) =
180+
if List.contains rootAccession xs then xs else xs @ [ rootAccession ]
181+
182+
if String.IsNullOrWhiteSpace rootAccession then set
183+
elif hasPrefix [ "PRJ" ] then { set with BioProjects = addDistinct set.BioProjects }
184+
elif hasPrefix [ "SRP"; "ERP"; "DRP" ] then { set with Studies = addDistinct set.Studies }
185+
else set
186+
167187
/// Fetches and parses the `filereport` for `rootAccession` using `options`
168-
/// (its Portal base URL, fetch function, retry count, and log sink).
188+
/// (its Portal base URL, fetch function, retry count, and log sink), then
189+
/// seeds the root itself (`withRoot`) so a childless project/study still
190+
/// yields its own record.
169191
let discoverAsync (options: CrawlOptions) (rootAccession: string) : Async<DiscoveredSet> =
170192
async {
171193
let url = Endpoints.portalFileReport options.PortalBaseUrl rootAccession
172194
let! tsv = Internal.Http.withRetry options.Retries options.Log options.Fetch url
173-
return parse tsv
195+
return parse tsv |> withRoot rootAccession
174196
}

‎src/BioFSharp.INSDC.Crawler/Log.fs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ open System
66
/// through `CrawlOptions.Log`; the built-in `Log.console` sink renders them to
77
/// stdout, and a custom sink can forward them to any logger (e.g. an `ILogger`).
88
type CrawlEvent =
9+
/// The crawl is starting — carries the root accession it was invoked with.
10+
/// Emitted before discovery so the very first log line identifies the crawl.
11+
| Started of accession: string
912
/// Discovery finished — a per-entity count of distinct accessions found.
1013
| Discovered of counts: Map<string, int>
1114
/// About to fetch `count` records of the named entity kind.
@@ -29,6 +32,7 @@ module Log =
2932
/// Renders `event` as a single human-readable line (no timestamp/prefix).
3033
let format (event: CrawlEvent) : string =
3134
match event with
35+
| Started accession -> sprintf "start — %s" accession
3236
| Discovered counts ->
3337
let parts =
3438
counts

‎src/BioFSharp.INSDC.SQLite/Internal/Sql.fs‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,37 @@ module Sql =
6868
use reader = cmd.ExecuteReader()
6969
if reader.Read() then Some (project reader) else None
7070

71+
/// The transaction currently active on `connection`, or `null` if none.
72+
/// `SqliteConnection` exposes no public accessor for it, but a freshly
73+
/// created command auto-binds its `Transaction` to the connection's active
74+
/// one (and leaves it `null` when there is none), so a throwaway command
75+
/// reveals it.
76+
let private activeTransaction (connection: SqliteConnection) : SqliteTransaction =
77+
use probe = connection.CreateCommand()
78+
probe.Transaction
79+
7180
/// Runs `work` inside an explicit transaction; commits on success, rolls
7281
/// back on any exception, and rethrows. Use this around the per-entity
7382
/// `insert` calls so a partial deconstruction never leaves orphan rows.
83+
///
84+
/// Reentrant: SQLite has no nested transactions (a second `BeginTransaction`
85+
/// throws), so when a transaction is already active on `connection` this
86+
/// joins it — running `work` inline and leaving the commit/rollback to the
87+
/// outermost owner. That lets a bulk caller wrap many per-entity `insert`s
88+
/// (each of which calls `withTransaction` itself) in one surrounding
89+
/// transaction, so the whole batch commits once instead of fsync-ing per
90+
/// record.
7491
let withTransaction (connection: SqliteConnection) (work: SqliteTransaction -> 'a) : 'a =
75-
use tx = connection.BeginTransaction()
76-
try
77-
let result = work tx
78-
tx.Commit()
79-
result
80-
with _ ->
81-
tx.Rollback()
82-
reraise ()
92+
let active = activeTransaction connection
93+
94+
if isNull active then
95+
use tx = connection.BeginTransaction()
96+
try
97+
let result = work tx
98+
tx.Commit()
99+
result
100+
with _ ->
101+
tx.Rollback()
102+
reraise ()
103+
else
104+
work active

‎tests/BioFSharp.INSDC.Tests/Tests.fs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,6 +1249,106 @@ type CrawlerTests() =
12491249
if File.Exists dbPath then
12501250
File.Delete dbPath
12511251

1252+
[<Fact>]
1253+
member _.``Discovery.withRoot seeds a childless root into the bucket its prefix implies`` () =
1254+
// A childless project/study yields a header-only report -> the empty set.
1255+
let empty = Discovery.parse "run_accession\texperiment_accession\n"
1256+
Assert.Empty empty.BioProjects
1257+
Assert.Empty empty.Studies
1258+
1259+
// The root is seeded so its own record is still fetched: PRJ... -> project,
1260+
// SRP/ERP/DRP... -> study.
1261+
Assert.Equal<string[]>([| "PRJNA999" |], Discovery.withRoot "PRJNA999" empty |> fun s -> List.toArray s.BioProjects)
1262+
Assert.Empty((Discovery.withRoot "PRJNA999" empty).Studies)
1263+
Assert.Equal<string[]>([| "SRP123456" |], Discovery.withRoot "SRP123456" empty |> fun s -> List.toArray s.Studies)
1264+
1265+
// An already-present root is not duplicated; an unrecognized prefix
1266+
// (e.g. a run accession) leaves every bucket untouched.
1267+
let parsed = Discovery.parse (TestFiles.fixtureText "crawl-PRJDB5192.filereport.tsv")
1268+
Assert.Equal<string[]>([| "PRJDB5192" |], Discovery.withRoot "PRJDB5192" parsed |> fun s -> List.toArray s.BioProjects)
1269+
Assert.Equal<string[]>(List.toArray parsed.BioProjects, Discovery.withRoot "DRR999" parsed |> fun s -> List.toArray s.BioProjects)
1270+
1271+
[<Fact>]
1272+
member _.``crawlToSqlite persists a childless project (no runs) via root seeding`` () =
1273+
// ENA returns a header-only filereport for a project with no runs, so
1274+
// discovery finds nothing to relate. The root must still be stored.
1275+
let headerOnly =
1276+
"run_accession\texperiment_accession\tsample_accession\tstudy_accession\t\
1277+
secondary_study_accession\tfastq_ftp\tfastq_md5\tfastq_bytes\n"
1278+
1279+
let stub (url: string) : Async<string> =
1280+
async {
1281+
return
1282+
if url.Contains "filereport" then headerOnly
1283+
elif url.Contains "PRJDB5192" then TestFiles.fixtureText "PRJDB5192.xml"
1284+
else failwithf "unexpected crawl URL: %s" url
1285+
}
1286+
1287+
let options = { CrawlOptions.Default with Fetch = stub; Log = Log.silent; ThrottleMs = 0 }
1288+
let dbPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.sqlite")
1289+
1290+
try
1291+
Crawler.crawlToSqliteWithAsync options "PRJDB5192" dbPath |> Async.RunSynchronously
1292+
1293+
(
1294+
use connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={dbPath}")
1295+
connection.Open()
1296+
1297+
Assert.True(
1298+
(BioFSharp.INSDC.SQLite.BioProject.tryGet connection "PRJDB5192").IsSome,
1299+
"childless BioProject was not persisted")
1300+
1301+
// No runs, so the connectivity table is legitimately empty.
1302+
Assert.Empty(BioFSharp.INSDC.SQLite.AccessionRelations.listAccessions connection |> Seq.toList)
1303+
)
1304+
finally
1305+
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools() |> ignore
1306+
1307+
if File.Exists dbPath then
1308+
File.Delete dbPath
1309+
1310+
[<Fact>]
1311+
member _.``crawl emits a Started event carrying the root accession as its first event`` () =
1312+
let events = System.Collections.Generic.List<CrawlEvent>()
1313+
let options = { CrawlerFixtures.options with Log = events.Add }
1314+
1315+
Crawler.crawlWithAsync options "PRJDB5192" |> Async.RunSynchronously |> ignore
1316+
1317+
match Seq.tryHead events with
1318+
| Some (Started accession) -> Assert.Equal("PRJDB5192", accession)
1319+
| other -> Assert.Fail(sprintf "expected a Started event first, got %A" other)
1320+
1321+
[<Fact>]
1322+
member _.``Sql.withTransaction is reentrant — a nested call joins the outer transaction`` () =
1323+
use connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:")
1324+
connection.Open()
1325+
let exec sql = BioFSharp.INSDC.SQLite.Internal.Sql.execNonQuery connection sql [] |> ignore
1326+
let count () =
1327+
BioFSharp.INSDC.SQLite.Internal.Sql.queryAll connection "SELECT COUNT(*) FROM t;" [] (fun r -> r.GetInt32 0)
1328+
|> List.head
1329+
1330+
exec "CREATE TABLE t (v INTEGER);"
1331+
1332+
// A nested withTransaction must not throw (SQLite has no nested
1333+
// transactions) — it joins the outer one, so both inserts commit together.
1334+
BioFSharp.INSDC.SQLite.Internal.Sql.withTransaction connection (fun _ ->
1335+
exec "INSERT INTO t VALUES (1);"
1336+
BioFSharp.INSDC.SQLite.Internal.Sql.withTransaction connection (fun _ ->
1337+
exec "INSERT INTO t VALUES (2);"))
1338+
Assert.Equal(2, count ())
1339+
1340+
// When the outer rolls back, the joined-inner write is discarded too —
1341+
// proof the inner did not commit independently.
1342+
try
1343+
BioFSharp.INSDC.SQLite.Internal.Sql.withTransaction connection (fun _ ->
1344+
exec "INSERT INTO t VALUES (3);"
1345+
BioFSharp.INSDC.SQLite.Internal.Sql.withTransaction connection (fun _ ->
1346+
exec "INSERT INTO t VALUES (4);")
1347+
failwith "boom")
1348+
with _ -> ()
1349+
1350+
Assert.Equal(2, count ())
1351+
12521352
[<Fact>]
12531353
member _.``LIVE crawl of a small public project (opt-in via INSDC_LIVE_TESTS=1)`` () =
12541354
// Off by default (AGENTS.md forbids network at test time). Set

0 commit comments

Comments
 (0)