diff --git a/.github/workflows/ci-build-manual-full.yml b/.github/workflows/ci-build-manual-full.yml index 04754dd039..ab386c7b91 100644 --- a/.github/workflows/ci-build-manual-full.yml +++ b/.github/workflows/ci-build-manual-full.yml @@ -66,8 +66,16 @@ jobs: GROBID_VERSION=${{ steps.version.outputs.value }} tags: | lfoppiano/grobid:latest-full,lfoppiano/grobid:${{ github.event.inputs.custom_tag }} - cache-from: type=gha,scope=full - cache-to: type=gha,mode=max,scope=full + # Registry cache rather than type=gha. The GitHub Actions cache is capped at + # 10GB per repository with LRU eviction, and mode=max on this image exports far + # more than that -- the torch/CUDA wheels alone are ~5GB and the embeddings lmdb + # ~2GB -- so entries were being evicted between runs and the "cached" layers + # rebuilt anyway. Docker Hub has no such ceiling, and the push happens over the + # login already done above. + # + # ignore-error keeps a failed cache export from failing the build itself. + cache-from: type=registry,ref=lfoppiano/grobid:buildcache + cache-to: type=registry,ref=lfoppiano/grobid:buildcache,mode=max,ignore-error=true - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} - name: Docker Image Summary diff --git a/.github/workflows/ci-build-unstable.yml b/.github/workflows/ci-build-unstable.yml index 445f1a2191..85160c703f 100644 --- a/.github/workflows/ci-build-unstable.yml +++ b/.github/workflows/ci-build-unstable.yml @@ -28,8 +28,18 @@ jobs: - name: Check code formatting run: ./gradlew spotlessCheck continue-on-error: false + # jacocoTestReport is deliberately not invoked here: it writes per-subproject reports + # that nothing downstream reads. Coveralls is fed by the aggregate `codeCoverageReport` + # task, which the `coveralls` step below derives from the test execution data. + - name: Build and test (with coverage) + if: matrix.os == 'ubuntu-latest' + run: ./gradlew assemble test --info --stacktrace + + # Every other leg skips the jacoco agent: its output is never published, and the agent + # instruments classes as they load in the test JVM. - name: Build and test - run: ./gradlew assemble test jacocoTestReport --info --stacktrace + if: matrix.os != 'ubuntu-latest' + run: ./gradlew assemble test -PskipCoverage --info --stacktrace - name: Run Coveralls Gradle task if: matrix.os == 'ubuntu-latest' diff --git a/Dockerfile.delft b/Dockerfile.delft index de85a75de8..3fe234348f 100644 --- a/Dockerfile.delft +++ b/Dockerfile.delft @@ -74,6 +74,14 @@ RUN unzip -o /opt/grobid-source/grobid-home/build/distributions/grobid-home-*.zi chmod -R 755 /opt/grobid/grobid-home/pdfalto RUN rm -rf grobid-source +# ----------------------------------------------- +# JDK, as a bind-mount source for the JEP build +# ----------------------------------------------- + +# Same image the builder starts from, so it costs no extra pull, but -- unlike `builder` -- +# its content does not move with the grobid source, which keeps the JEP layer cacheable. +FROM eclipse-temurin:21.0.11_10-jdk-noble AS jdk + # ------------------- # build runtime image # ------------------- @@ -109,21 +117,44 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ WORKDIR /opt/grobid -COPY --from=builder /opt/grobid . +# Everything from here to the "COPY --from=builder /opt/grobid ." below is ordered +# stable-first, on purpose. The Java distribution changes with every grobid commit, so it +# goes last: the DeLFT install, the JEP build and the glove preload (~2GB downloaded, then +# loaded into lmdb) all sit above it and stay cached across ordinary source changes. Only a +# version bump, a base-image change or a resources-registry edit rebuilds them. + +# link the data directory to /data +# the current working directory will most likely be /opt/grobid +# (must precede the embeddings preload: the registry writes the lmdb under ./data) +RUN mkdir -p /data \ + && ln -s /data /opt/grobid/data \ + && ln -s /data ./data # install DeLFT RUN pip3 install --no-cache-dir delft==0.4.6 # Build JEP with temporary access to full JDK (needs javac + JNI headers). # The bind mount is only visible during this RUN step — zero overhead in the final image. -RUN --mount=from=builder,source=/opt/java/openjdk,target=/opt/java/openjdk-jdk \ +# Mounted from the `jdk` stage rather than from `builder`, so this layer does not depend on +# a stage that is rebuilt on every commit. +RUN --mount=from=jdk,source=/opt/java/openjdk,target=/opt/java/openjdk-jdk \ JAVA_HOME=/opt/java/openjdk-jdk pip3 install --no-cache-dir jep==4.3.1 -# link the data directory to /data -# the current working directory will most likely be /opt/grobid -RUN mkdir -p /data \ - && ln -s /data /opt/grobid/data \ - && ln -s /data ./data +# preload embeddings, for GROBID all the RNN models use glove-840B (default for the script), ELMo is currently not loaded +# to be done: mechanism to download GROBID fine-tuned models based on SciBERT if selected (but not good enough for the moment) +# Only these two files are taken from the builder, and BuildKit keys the layer on their +# content, so the preload is rebuilt when the registry changes -- not on every commit. +COPY --from=builder /opt/grobid-source/grobid-home/scripts/preload_embeddings.py . +COPY --from=builder /opt/grobid-source/grobid-home/config/resources-registry.json . +# The lmdb permissions are set here rather than in the chgrp/chmod step at the end of the +# file: a recursive chmod in a later layer copies up every file it touches, which would put +# a second copy of the multi-GB embeddings database in the image. +RUN python3 preload_embeddings.py --registry ./resources-registry.json && \ + ln -s /opt/grobid /opt/delft && \ + chgrp -R 0 /data && chmod -R g=u /data + +RUN mkdir delft && \ + cp ./resources-registry.json delft/ # disable python warnings (and fix logging) ENV PYTHONWARNINGS="ignore" @@ -142,19 +173,14 @@ ENTRYPOINT ["/tini", "-s", "--"] WORKDIR /opt/grobid -# preload embeddings, for GROBID all the RNN models use glove-840B (default for the script), ELMo is currently not loaded -# to be done: mechanism to download GROBID fine-tuned models based on SciBERT if selected (but not good enough for the moment) -COPY --from=builder /opt/grobid-source/grobid-home/scripts/preload_embeddings.py . -COPY --from=builder /opt/grobid-source/grobid-home/config/resources-registry.json . -RUN python3 preload_embeddings.py --registry ./resources-registry.json && \ - ln -s /opt/grobid /opt/delft - -RUN mkdir delft && \ - cp ./resources-registry.json delft/ +# The Java distribution, last: it changes with every grobid commit, so nothing cacheable +# should sit below it. +COPY --from=builder /opt/grobid . # Set the group to avoid running the docker image in a rootless environment -RUN chgrp -R 0 /opt/grobid /data /tmp && \ - chmod -R g=u /opt/grobid /data /tmp +# (/data is handled above, in the layer that fills it) +RUN chgrp -R 0 /opt/grobid /tmp && \ + chmod -R g=u /opt/grobid /tmp CMD ["./grobid-service/bin/grobid-service"] diff --git a/build.gradle b/build.gradle index 449500430c..c806455ff7 100644 --- a/build.gradle +++ b/build.gradle @@ -246,8 +246,23 @@ subprojects { useJUnitPlatform() testLogging.showStandardStreams = true - // enable for having separate test executor for different tests - forkEvery = 1 + + // One JVM per test task, not one per test class. + // + // This was forkEvery = 1, which spawns a fresh JVM for every test class -- 109 of + // them, run strictly one at a time (maxParallelForks is 1, and + // org.gradle.workers.max=1 in gradle.properties would cap it regardless). Each + // start pays JVM boot, the 1g heap below, the Mockito -javaagent, and + // java.library.path native library loading. + // + // Measured locally with `test --rerun-tasks -PskipCoverage`, same machine: + // 9m20s at forkEvery = 1 against 4m07s here, running an identical 812 tests across + // 109 classes with no failures. The difference is almost entirely process startup. + // + // The tests do share a JVM now, so anything depending on per-class isolation -- + // static state in GrobidProperties, or a native library loaded through + // LibraryLoader -- would surface here rather than being papered over. + forkEvery = 0 maxHeapSize = "1024m" def javaLibraryPath = getJavaLibraryPath() @@ -737,6 +752,24 @@ project(":grobid-trainer") { /** JACOCO **/ +// Coverage instrumentation is opt-out, for CI legs that do not publish it. +// +// The jacoco agent attaches to every forked test JVM and instruments classes as they load. +// Only one CI leg feeds coveralls; on the others that cost is paid and thrown away. +// +// configureEach (rather than a plain withType) so this resolves after the subprojects have +// applied the jacoco plugin, and findByType so projects without it are simply skipped. +if (providers.gradleProperty('skipCoverage').present) { + allprojects { + tasks.withType(Test).configureEach { + def jacocoExtension = it.extensions.findByType(JacocoTaskExtension) + if (jacocoExtension != null) { + jacocoExtension.enabled = false + } + } + } +} + tasks.register("codeCoverageReport", JacocoReport) { // If a subproject applies the 'jacoco' plugin, add the result it to the report subprojects { subproject ->