Skip to content

graflo.hq.document_caster

Stateless document-to-graph casting (no I/O).

Attributes

logger = logging.getLogger(__name__) module-attribute

Classes

DocumentCaster

Cast source documents to :class:GraphContainer via ingestion resources.

Source code in graflo/hq/document_caster.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
class DocumentCaster:
    """Cast source documents to :class:`GraphContainer` via ingestion resources."""

    # Concurrent sources may interleave batches of different resources; keeping a
    # small set of pools alive avoids tearing one down (and re-importing graflo in
    # every worker) each time the resource changes.
    _MAX_LIVE_POOLS = 4

    def __init__(self, ingestion_model: IngestionModel) -> None:
        self.ingestion_model = ingestion_model
        # Worker startup is dominated by importing graflo and rebuilding the runtime,
        # which costs far more than casting a batch. Pools are therefore kept alive
        # across batches, one per (resource, workers), LRU-bounded.
        self._pools: OrderedDict[tuple[str, int], ProcessPoolExecutor] = OrderedDict()
        # Serializing a resource + its schema is not free, and it is identical for
        # every batch of that resource.
        self._spec_cache: dict[str, CastSpec | None] = {}
        self._cache_lock = threading.Lock()

    def close(self) -> None:
        """Shut down all cast worker pools, if any were started."""
        with self._cache_lock:
            pools = list(self._pools.values())
            self._pools.clear()
        for pool in pools:
            pool.shutdown(wait=True)

    def _get_pool(
        self, spec: CastSpec, workers: int, *, resource_name: str
    ) -> ProcessPoolExecutor:
        key = (resource_name, workers)
        evicted: ProcessPoolExecutor | None = None
        with self._cache_lock:
            pool = self._pools.get(key)
            if pool is not None:
                self._pools.move_to_end(key)
                return pool
            pool = ProcessPoolExecutor(
                max_workers=workers,
                mp_context=_MP_CONTEXT,
                initializer=init_worker,
                initargs=(spec,),
            )
            self._pools[key] = pool
            if len(self._pools) > self._MAX_LIVE_POOLS:
                _, evicted = self._pools.popitem(last=False)
        if evicted is not None:
            evicted.shutdown(wait=True)
        return pool

    async def cast_batch(
        self,
        data: Iterable[Any],
        resource_name: str | None,
        *,
        params: IngestionParams,
        allowed_vertex_names: set[str] | None = None,
    ) -> CastBatchResult:
        runtime = self.ingestion_model.fetch_resource(resource_name)
        resolved_name = runtime.name
        vertex_filter = resolve_effective_vertex_names(
            runtime.collect_vertex_names(),
            allowed_vertex_names=allowed_vertex_names,
        )

        docs = [_coerce_doc(doc) for doc in data]
        mode = self._resolve_cast_mode(params, n_docs=len(docs))
        workers = max(1, params.n_cores)

        if mode == "process":
            spec = self._cast_spec(runtime, params=params)
            if spec is None:
                mode = "thread"
            else:
                try:
                    return await self._cast_batch_in_processes(
                        spec,
                        docs,
                        attached_by=runtime.attached_selectors,
                        workers=workers,
                        resolved_name=resolved_name,
                        vertex_filter=vertex_filter,
                        params=params,
                    )
                except WorkerCastError:
                    # on_doc_error="fail": a document failed, not the pool.
                    raise
                except Exception as exc:
                    logger.warning(
                        "Process-pool casting unavailable (%s: %s); falling back to "
                        "in-process casting for resource %r.",
                        type(exc).__name__,
                        exc,
                        runtime.name,
                    )
                    mode = "thread"

        # Dynamic edge feedback mutates the shared edge_config *during* casting;
        # spreading documents over threads would race those registrations, so the
        # fallback for that configuration is single-threaded, not "thread".
        if mode == "thread" and params.dynamic_edges:
            mode = "inline"

        raw = await self._cast_documents(
            runtime, docs, n_cores=workers if mode == "thread" else 1
        )
        cast_results, failures = self._collect_cast_results(
            raw,
            docs,
            on_doc_error=params.on_doc_error,
            resolved_name=resolved_name,
            params=params,
        )

        done = [r for r in cast_results if isinstance(r, ResourceCastResult)]
        graph = self._assemble(
            runtime, done, vertex_filter=vertex_filter, params=params
        )
        return CastBatchResult(graph=graph, failures=failures)

    async def cast_batch_by_key(
        self,
        data: Sequence[Any],
        keys: Sequence[Hashable],
        resource_name: str | None,
        *,
        params: IngestionParams,
        allowed_vertex_names: set[str] | None = None,
    ) -> tuple[list[tuple[Hashable, GraphContainer]], list[DocCastFailure]]:
        """Cast *data* into one container per distinct key of its records.

        ``keys[i]`` is the key of ``data[i]``. Each container holds only what
        records of its key cast to; containers come in the order their key is
        first seen. Failures carry their record's index in *data*.
        """
        positions: dict[Hashable, list[int]] = {}
        for index, key in enumerate(keys):
            positions.setdefault(key, []).append(index)
        if len(positions) <= 1:
            result = await self.cast_batch(
                data,
                resource_name,
                params=params,
                allowed_vertex_names=allowed_vertex_names,
            )
            return [(key, result.graph) for key in positions], result.failures

        runtime = self.ingestion_model.fetch_resource(resource_name)
        docs = [_coerce_doc(doc) for doc in data]
        mode = self._resolve_cast_mode(params, n_docs=len(docs))
        if mode == "process" and self._cast_spec(runtime, params=params) is None:
            mode = "thread"
        if mode == "process":
            # A worker-cast resource has no order-dependent steps, so each key's
            # records cast as a batch of their own.
            parts: list[tuple[Hashable, GraphContainer]] = []
            failures: list[DocCastFailure] = []
            for key, indices in positions.items():
                result = await self.cast_batch(
                    [docs[i] for i in indices],
                    resource_name,
                    params=params,
                    allowed_vertex_names=allowed_vertex_names,
                )
                parts.append((key, result.graph))
                failures.extend(
                    f.model_copy(update={"doc_index": indices[f.doc_index]})
                    for f in result.failures
                )
            return parts, failures

        # In process, records cast once in source order (dynamic edges depend
        # on it), and the results are split by key afterwards.
        if mode == "thread" and params.dynamic_edges:
            mode = "inline"
        workers = max(1, params.n_cores)
        raw = await self._cast_documents(
            runtime, docs, n_cores=workers if mode == "thread" else 1
        )
        cast_results, failures = self._collect_cast_results(
            raw,
            docs,
            on_doc_error=params.on_doc_error,
            resolved_name=runtime.name,
            params=params,
        )
        cast_at = dict(
            zip(
                (
                    i
                    for i, item in enumerate(raw)
                    if not isinstance(item, BaseException)
                ),
                cast_results,
            )
        )
        vertex_filter = resolve_effective_vertex_names(
            runtime.collect_vertex_names(),
            allowed_vertex_names=allowed_vertex_names,
        )
        parts = []
        for key, indices in positions.items():
            done = [
                result
                for i in indices
                if isinstance(result := cast_at.get(i), ResourceCastResult)
            ]
            parts.append(
                (
                    key,
                    self._assemble(
                        runtime, done, vertex_filter=vertex_filter, params=params
                    ),
                )
            )
        return parts, failures

    @staticmethod
    def _assemble(
        runtime: ResourceRuntime,
        done: list[ResourceCastResult],
        *,
        vertex_filter: set[str] | None,
        params: IngestionParams,
    ) -> GraphContainer:
        """Fold cast records into one container, filtered as the params ask."""
        graph = GraphContainer.from_docs_list(
            [r.entities for r in done],
            attached=[r.attached for r in done],
            attached_by=runtime.attached_selectors,
        )
        filter_graph_container_by_vertices_inplace(
            graph, allowed_vertex_names=vertex_filter
        )
        if params.drop_empty_identity_docs:
            filter_graph_container_drop_empty_identity_inplace(
                graph,
                vertex_config=runtime.vertex_config,
                edge_derivation=runtime.edge_derivation,
            )
        return graph

    def _resolve_cast_mode(
        self, params: IngestionParams, *, n_docs: int
    ) -> Literal["inline", "thread", "process"]:
        """Resolve ``auto`` to a concrete executor for this batch.

        ``auto`` reaches for processes only when there is real CPU work to win
        back: more than one core requested, no dynamic-edge feedback (which
        cannot cross a process boundary), and enough documents that each worker
        gets at least a full chunk to amortize pickling documents out and the
        folded entities back. Workers fold and filter their own chunks, so the
        parent-side serial cost that used to cancel out the process-pool gain
        (~3x on casting over 8 workers) no longer applies.
        """
        mode = params.cast_executor
        if mode != "auto":
            return mode
        if (
            params.n_cores > 1
            and not params.dynamic_edges
            and n_docs >= params.n_cores * _MIN_DOCS_PER_CAST_CHUNK
        ):
            return "process"
        return "inline"

    def _collect_cast_results(
        self,
        raw: list[ResourceCastResult | Exception],
        docs: list[dict[str, Any]],
        *,
        on_doc_error: Literal["fail", "skip"],
        resolved_name: str,
        params: IngestionParams,
    ) -> tuple[list[ResourceCastResult | BaseException], list[DocCastFailure]]:
        if on_doc_error == "fail":
            # Preserve "the first failing document aborts the batch", by document
            # order rather than by whichever worker happened to fail first.
            for item in raw:
                if isinstance(item, BaseException):
                    raise item

        cast_results: list[ResourceCastResult | BaseException] = []
        failures: list[DocCastFailure] = []
        for i, item in enumerate(raw):
            doc = docs[i]
            if isinstance(item, BaseException):
                failures.append(
                    _doc_failure_from_exception(
                        resource_name=resolved_name,
                        doc_index=i,
                        doc=doc,
                        exc=item,
                        doc_keys=params.doc_error_preview_keys,
                        doc_preview_max_bytes=params.doc_error_preview_max_bytes,
                    )
                )
                continue
            failures.extend(
                _transform_failures_to_doc_cast_failures(
                    resource_name=resolved_name,
                    doc_index=i,
                    doc=doc,
                    transform_failures=item.transform_failures,
                    doc_keys=params.doc_error_preview_keys,
                    doc_preview_max_bytes=params.doc_error_preview_max_bytes,
                )
            )
            cast_results.append(item)
        return cast_results, failures

    def _cast_spec(
        self, runtime: ResourceRuntime, *, params: IngestionParams
    ) -> CastSpec | None:
        """Serializable description of *runtime*, or ``None`` if it cannot cross.

        Dynamic edge feedback registers new edges on the shared ``edge_config``
        *during* casting. In a worker process those registrations would be invisible
        to the parent's db-aware projection, so that configuration stays in-process.
        So does a resource with a step that names its edges per document: each
        worker would register them on its own copy of the runtime.
        """
        if params.dynamic_edges or runtime.has_dynamic_edge_steps:
            return None
        with self._cache_lock:
            if runtime.name in self._spec_cache:
                return self._spec_cache[runtime.name]
        spec = self._build_cast_spec(runtime, params=params)
        with self._cache_lock:
            return self._spec_cache.setdefault(runtime.name, spec)

    def _build_cast_spec(
        self, runtime: ResourceRuntime, *, params: IngestionParams
    ) -> CastSpec | None:
        try:
            return CastSpec(
                resource=runtime.config.to_dict(skip_defaults=False),
                vertex_config=runtime.vertex_config.to_dict(skip_defaults=False),
                edge_config=runtime.edge_config.to_dict(skip_defaults=False),
                transforms=[
                    proto.to_dict(skip_defaults=False)
                    for proto in self.ingestion_model.transforms
                ],
                strict_references=params.strict_references,
                target_db_flavor=_flavor_value(runtime.target_db_flavor),
            )
        except Exception as exc:
            logger.warning(
                "Resource %r cannot be described for worker processes (%s: %s); "
                "casting it in process instead.",
                runtime.name,
                type(exc).__name__,
                exc,
            )
            return None

    async def _cast_batch_in_processes(
        self,
        spec: CastSpec,
        docs: list[dict[str, Any]],
        *,
        attached_by: dict[str, str],
        workers: int,
        resolved_name: str,
        vertex_filter: set[str] | None,
        params: IngestionParams,
    ) -> CastBatchResult:
        """Cast *docs* across worker processes, preserving document order.

        Workers fold and filter their own contiguous chunks; the parent only
        concatenates the partial containers in chunk order, which yields the
        identical graph to a serial fold over the whole batch.
        """
        n_workers = default_worker_count(workers)
        size = -(-len(docs) // n_workers)
        chunks = [docs[i : i + size] for i in range(0, len(docs), size)]
        offsets = list(range(0, len(docs), size))
        filter_names = sorted(vertex_filter) if vertex_filter is not None else None

        loop = asyncio.get_running_loop()
        pool = self._get_pool(spec, n_workers, resource_name=resolved_name)
        # Only documents cross here: the workers were primed with the spec.
        parts = await asyncio.gather(
            *[
                loop.run_in_executor(
                    pool,
                    cast_chunk,
                    chunk,
                    filter_names,
                    params.drop_empty_identity_docs,
                )
                for chunk in chunks
            ]
        )

        if params.on_doc_error == "fail":
            # Chunks are contiguous and in order, so the first chunk reporting an
            # error holds the failing document with the smallest global index.
            for part in parts:
                errors = part[4]
                if errors:
                    _, error = min(errors, key=lambda e: e[0])
                    raise WorkerCastError(error)

        vertices: dict[str, list] = {}
        edges: dict[Any, list] = {}
        linear: list[Any] = []
        attached: dict[str, list] = {}
        failures: list[DocCastFailure] = []
        for offset, part in zip(offsets, parts):
            (
                part_vertices,
                part_edges,
                part_linear,
                transform_failures,
                errors,
                part_attached,
            ) = part
            for k, v in part_vertices.items():
                vertices.setdefault(k, []).extend(v)
            for k, v in part_attached.items():
                attached.setdefault(k, []).extend(v)
            for k, v in part_edges.items():
                edges.setdefault(k, []).extend(v)
            linear.extend(part_linear)
            # A document either failed outright or carries transform failures,
            # never both; merging by local index restores document order.
            per_doc: list[tuple[int, tuple[str, str, str] | None, list]] = [
                (i, error, []) for i, error in errors
            ] + [(i, None, tfails) for i, tfails in transform_failures]
            for local_idx, error, tfails in sorted(per_doc, key=lambda t: t[0]):
                doc = docs[offset + local_idx]
                if error is not None:
                    failures.append(
                        _doc_failure_from_exception(
                            resource_name=resolved_name,
                            doc_index=offset + local_idx,
                            doc=doc,
                            exc=WorkerCastError(error),
                            doc_keys=params.doc_error_preview_keys,
                            doc_preview_max_bytes=params.doc_error_preview_max_bytes,
                        )
                    )
                    continue
                failures.extend(
                    _transform_failures_to_doc_cast_failures(
                        resource_name=resolved_name,
                        doc_index=offset + local_idx,
                        doc=doc,
                        transform_failures=tfails,
                        doc_keys=params.doc_error_preview_keys,
                        doc_preview_max_bytes=params.doc_error_preview_max_bytes,
                    )
                )

        graph = GraphContainer(
            vertices=vertices,
            edges=edges,
            linear=linear,
            attached=attached,
            attached_by=dict(attached_by),
        )
        return CastBatchResult(graph=graph, failures=failures)

    async def _cast_documents(
        self,
        runtime: ResourceRuntime,
        docs: list[dict[str, Any]],
        *,
        n_cores: int,
    ) -> list[ResourceCastResult | Exception]:
        """Cast *docs* in document order, spreading contiguous slices over workers.

        A slice per worker rather than a task per document: ``cast_document`` is
        GIL-bound, so the dispatch was costing more than the work. Anything beyond
        a trivially small batch still crosses into a worker thread once, to keep
        the event loop free for batch prefetch.
        """
        if not docs:
            return []

        workers = max(1, n_cores)
        if workers == 1 and len(docs) <= _MIN_DOCS_PER_CAST_CHUNK:
            return _cast_chunk(runtime, docs)

        max_useful = -(-len(docs) // _MIN_DOCS_PER_CAST_CHUNK)
        n_chunks = max(1, min(workers, max_useful))
        size = -(-len(docs) // n_chunks)
        chunks = [docs[i : i + size] for i in range(0, len(docs), size)]

        parts = await asyncio.gather(
            *[asyncio.to_thread(_cast_chunk, runtime, chunk) for chunk in chunks],
            return_exceptions=True,
        )
        out: list[ResourceCastResult | Exception] = []
        for part in parts:
            if isinstance(part, BaseException):
                # A chunk raised outside per-document capture (cancellation, exit):
                # abort the batch rather than reporting it against one document.
                raise part
            out.extend(part)
        return out

Attributes

ingestion_model = ingestion_model instance-attribute

Methods:

__init__(ingestion_model)
Source code in graflo/hq/document_caster.py
def __init__(self, ingestion_model: IngestionModel) -> None:
    self.ingestion_model = ingestion_model
    # Worker startup is dominated by importing graflo and rebuilding the runtime,
    # which costs far more than casting a batch. Pools are therefore kept alive
    # across batches, one per (resource, workers), LRU-bounded.
    self._pools: OrderedDict[tuple[str, int], ProcessPoolExecutor] = OrderedDict()
    # Serializing a resource + its schema is not free, and it is identical for
    # every batch of that resource.
    self._spec_cache: dict[str, CastSpec | None] = {}
    self._cache_lock = threading.Lock()
cast_batch(data, resource_name, *, params, allowed_vertex_names=None) async
Source code in graflo/hq/document_caster.py
async def cast_batch(
    self,
    data: Iterable[Any],
    resource_name: str | None,
    *,
    params: IngestionParams,
    allowed_vertex_names: set[str] | None = None,
) -> CastBatchResult:
    runtime = self.ingestion_model.fetch_resource(resource_name)
    resolved_name = runtime.name
    vertex_filter = resolve_effective_vertex_names(
        runtime.collect_vertex_names(),
        allowed_vertex_names=allowed_vertex_names,
    )

    docs = [_coerce_doc(doc) for doc in data]
    mode = self._resolve_cast_mode(params, n_docs=len(docs))
    workers = max(1, params.n_cores)

    if mode == "process":
        spec = self._cast_spec(runtime, params=params)
        if spec is None:
            mode = "thread"
        else:
            try:
                return await self._cast_batch_in_processes(
                    spec,
                    docs,
                    attached_by=runtime.attached_selectors,
                    workers=workers,
                    resolved_name=resolved_name,
                    vertex_filter=vertex_filter,
                    params=params,
                )
            except WorkerCastError:
                # on_doc_error="fail": a document failed, not the pool.
                raise
            except Exception as exc:
                logger.warning(
                    "Process-pool casting unavailable (%s: %s); falling back to "
                    "in-process casting for resource %r.",
                    type(exc).__name__,
                    exc,
                    runtime.name,
                )
                mode = "thread"

    # Dynamic edge feedback mutates the shared edge_config *during* casting;
    # spreading documents over threads would race those registrations, so the
    # fallback for that configuration is single-threaded, not "thread".
    if mode == "thread" and params.dynamic_edges:
        mode = "inline"

    raw = await self._cast_documents(
        runtime, docs, n_cores=workers if mode == "thread" else 1
    )
    cast_results, failures = self._collect_cast_results(
        raw,
        docs,
        on_doc_error=params.on_doc_error,
        resolved_name=resolved_name,
        params=params,
    )

    done = [r for r in cast_results if isinstance(r, ResourceCastResult)]
    graph = self._assemble(
        runtime, done, vertex_filter=vertex_filter, params=params
    )
    return CastBatchResult(graph=graph, failures=failures)
cast_batch_by_key(data, keys, resource_name, *, params, allowed_vertex_names=None) async

Cast data into one container per distinct key of its records.

keys[i] is the key of data[i]. Each container holds only what records of its key cast to; containers come in the order their key is first seen. Failures carry their record's index in data.

Source code in graflo/hq/document_caster.py
async def cast_batch_by_key(
    self,
    data: Sequence[Any],
    keys: Sequence[Hashable],
    resource_name: str | None,
    *,
    params: IngestionParams,
    allowed_vertex_names: set[str] | None = None,
) -> tuple[list[tuple[Hashable, GraphContainer]], list[DocCastFailure]]:
    """Cast *data* into one container per distinct key of its records.

    ``keys[i]`` is the key of ``data[i]``. Each container holds only what
    records of its key cast to; containers come in the order their key is
    first seen. Failures carry their record's index in *data*.
    """
    positions: dict[Hashable, list[int]] = {}
    for index, key in enumerate(keys):
        positions.setdefault(key, []).append(index)
    if len(positions) <= 1:
        result = await self.cast_batch(
            data,
            resource_name,
            params=params,
            allowed_vertex_names=allowed_vertex_names,
        )
        return [(key, result.graph) for key in positions], result.failures

    runtime = self.ingestion_model.fetch_resource(resource_name)
    docs = [_coerce_doc(doc) for doc in data]
    mode = self._resolve_cast_mode(params, n_docs=len(docs))
    if mode == "process" and self._cast_spec(runtime, params=params) is None:
        mode = "thread"
    if mode == "process":
        # A worker-cast resource has no order-dependent steps, so each key's
        # records cast as a batch of their own.
        parts: list[tuple[Hashable, GraphContainer]] = []
        failures: list[DocCastFailure] = []
        for key, indices in positions.items():
            result = await self.cast_batch(
                [docs[i] for i in indices],
                resource_name,
                params=params,
                allowed_vertex_names=allowed_vertex_names,
            )
            parts.append((key, result.graph))
            failures.extend(
                f.model_copy(update={"doc_index": indices[f.doc_index]})
                for f in result.failures
            )
        return parts, failures

    # In process, records cast once in source order (dynamic edges depend
    # on it), and the results are split by key afterwards.
    if mode == "thread" and params.dynamic_edges:
        mode = "inline"
    workers = max(1, params.n_cores)
    raw = await self._cast_documents(
        runtime, docs, n_cores=workers if mode == "thread" else 1
    )
    cast_results, failures = self._collect_cast_results(
        raw,
        docs,
        on_doc_error=params.on_doc_error,
        resolved_name=runtime.name,
        params=params,
    )
    cast_at = dict(
        zip(
            (
                i
                for i, item in enumerate(raw)
                if not isinstance(item, BaseException)
            ),
            cast_results,
        )
    )
    vertex_filter = resolve_effective_vertex_names(
        runtime.collect_vertex_names(),
        allowed_vertex_names=allowed_vertex_names,
    )
    parts = []
    for key, indices in positions.items():
        done = [
            result
            for i in indices
            if isinstance(result := cast_at.get(i), ResourceCastResult)
        ]
        parts.append(
            (
                key,
                self._assemble(
                    runtime, done, vertex_filter=vertex_filter, params=params
                ),
            )
        )
    return parts, failures
close()

Shut down all cast worker pools, if any were started.

Source code in graflo/hq/document_caster.py
def close(self) -> None:
    """Shut down all cast worker pools, if any were started."""
    with self._cache_lock:
        pools = list(self._pools.values())
        self._pools.clear()
    for pool in pools:
        pool.shutdown(wait=True)

Functions:

filter_graph_container_by_vertices_inplace(gc, *, allowed_vertex_names)

Restrict persistence to a subset of vertex types (in-place).

Source code in graflo/hq/document_caster.py
def filter_graph_container_by_vertices_inplace(
    gc: GraphContainer, *, allowed_vertex_names: set[str] | None
) -> None:
    """Restrict persistence to a subset of vertex types (in-place)."""
    if allowed_vertex_names is None:
        return
    gc.vertices = {
        vcol: items
        for vcol, items in gc.vertices.items()
        if vcol in allowed_vertex_names
    }
    gc.attached = {
        vcol: items
        for vcol, items in gc.attached.items()
        if vcol in allowed_vertex_names
    }
    gc.edges = {
        (vfrom, vto, rel): items
        for (vfrom, vto, rel), items in gc.edges.items()
        if vfrom in allowed_vertex_names and vto in allowed_vertex_names
    }

filter_graph_container_drop_empty_identity_inplace(gc, *, vertex_config, edge_derivation=None)

Remove vertex docs and edge tuples with no usable schema identity.

An endpoint declared by a secondary identity is judged on that field-set: it carries no primary key by construction, so checking the primary identity would discard exactly the edges this resolution path exists to write. An attached document (a find step) is judged on the fields it is found by, for the same reason.

Whatever is removed is logged: a keyless document usually means a resource references the vertex rather than owning it, and dropping its records without a word hides exactly that.

Source code in graflo/hq/document_caster.py
def filter_graph_container_drop_empty_identity_inplace(
    gc: GraphContainer,
    *,
    vertex_config: VertexConfig,
    edge_derivation: Any | None = None,
) -> None:
    """Remove vertex docs and edge tuples with no usable schema identity.

    An endpoint declared by a secondary identity is judged on *that* field-set:
    it carries no primary key by construction, so checking the primary identity
    would discard exactly the edges this resolution path exists to write. An
    attached document (a ``find`` step) is judged on the fields it is found by,
    for the same reason.

    Whatever is removed is logged: a keyless document usually means a
    resource references the vertex rather than owning it, and dropping its
    records without a word hides exactly that.
    """
    blank = set(vertex_config.blank_vertices)
    assigned = set(vertex_config.assigned_vertices)
    skip_minted = blank | assigned
    vertex_set = vertex_config.vertex_set

    for vcol, docs in list(gc.vertices.items()):
        if vcol in skip_minted or vcol not in vertex_set:
            continue
        id_fields = vertex_config.identity_fields(vcol)
        kept_docs = [
            d for d in docs if not _vertex_doc_has_empty_identity(d, id_fields)
        ]
        if len(kept_docs) < len(docs):
            logger.warning(
                "Cast dropped %s '%s' document(s) with no value for its identity "
                "%s. Mark the step lookup_only if the resource only references "
                "this vertex, or give it find to write onto the vertex a "
                "secondary identity finds.",
                len(docs) - len(kept_docs),
                vcol,
                id_fields,
            )
        gc.vertices[vcol] = kept_docs

    for vcol, docs in list(gc.attached.items()):
        selector = gc.attached_by.get(vcol)
        if vcol not in vertex_set or selector is None:
            continue
        find_fields = vertex_config.match_fields(vcol, selector)
        kept_docs = [
            d for d in docs if not _vertex_doc_has_empty_identity(d, find_fields)
        ]
        if len(kept_docs) < len(docs):
            logger.warning(
                "Cast dropped %s attached '%s' document(s) with no value for the "
                "fields find %r locates them by (%s).",
                len(docs) - len(kept_docs),
                vcol,
                selector,
                find_fields,
            )
        gc.attached[vcol] = kept_docs

    for edge_id, docs in list(gc.edges.items()):
        vfrom, vto, _rel = edge_id
        if vfrom not in vertex_set or vto not in vertex_set:
            continue
        match = (
            edge_derivation.endpoint_match_for(edge_id)
            if edge_derivation is not None
            else None
        )
        if match is not None:
            src_ids = vertex_config.match_fields(vfrom, match.source)
            tgt_ids = vertex_config.match_fields(vto, match.target)
        else:
            src_ids = vertex_config.identity_fields(vfrom)
            tgt_ids = vertex_config.identity_fields(vto)
        # A blank or assigned endpoint may still be minted; the other is checked.
        check_src = vfrom not in skip_minted
        check_tgt = vto not in skip_minted
        if not (check_src or check_tgt):
            continue
        kept = [
            t
            for t in docs
            if not (check_src and _vertex_doc_has_empty_identity(t[0], src_ids))
            and not (check_tgt and _vertex_doc_has_empty_identity(t[1], tgt_ids))
        ]
        if len(kept) < len(docs):
            logger.warning(
                "Cast dropped %s %s edge(s) whose endpoint has no value for the "
                "fields it is matched on (%s / %s)",
                len(docs) - len(kept),
                edge_id,
                src_ids,
                tgt_ids,
            )
        if kept:
            gc.edges[edge_id] = kept
        else:
            del gc.edges[edge_id]