Deploy on Kubernetes
The emit/poll split maps cleanly onto Kubernetes. The expensive analysis becomes scheduled batch work, the database is a stateful service, and the agents are stateless readers that scale on demand.
flowchart TB
subgraph Jobs["Emit · CronJobs (one per project/language)"]
EJ["codeanalyzer-java"]
EP["canpy"]
ET["cants"]
end
subgraph Core["Neo4j (StatefulSet or managed Aura)"]
NS[("graph<br/>J* · Py* · TS*")]
end
subgraph Read["Poll · agent Deployment (stateless, scalable)"]
D1["agent replica"]
D2["agent replica"]
D3["agent replica"]
end
EJ -->|Bolt write| NS
EP -->|Bolt write| NS
ET -->|Bolt write| NS
NS -->|Bolt read| D1
NS -->|Bolt read| D2
NS -->|Bolt read| D3
Three roles:
- Neo4j. One database for the whole fleet. Run it as a
StatefulSetwith aPersistentVolumeClaim, or point at a managed instance such as Neo4j Aura. The emit jobs need write credentials. The agents need only read credentials. - Emit
CronJobs. One for each project, and one for each language in that project. They run on a schedule, push incrementally over Bolt, and exit. Writes are idempotent, so a missed run or a repeated run is harmless. - Agent
Deployment. Long-lived pods that buildanalysisobjects against the graph withNeo4jConnectionConfig. They hold no analysis state, so you scale them horizontally like any other web service.
The emit CronJob
Section titled “The emit CronJob”Each backend ships as a self-contained binary: canjv for Java, canpy for Python, and cants for TypeScript. An emit job is therefore a container with the binary, the source checkout, and the Neo4j connection in its environment. The backend reads the connection values from NEO4J_URI, NEO4J_USERNAME, and NEO4J_PASSWORD, so the command line carries no secrets.
apiVersion: batch/v1kind: CronJobmetadata: name: emit-billing-corespec: schedule: "0 * * * *" # hourly; incremental pushes are cheap concurrencyPolicy: Forbid jobTemplate: spec: template: spec: restartPolicy: Never containers: - name: canpy image: ghcr.io/your-org/cldk-emit-python:latest args: - canpy - -i - /src/billing-core - --emit - neo4j - --app-name - billing-core env: - name: NEO4J_URI value: bolt://neo4j.cldk.svc.cluster.local:7687 - name: NEO4J_USERNAME value: writer - name: NEO4J_PASSWORD valueFrom: secretKeyRef: name: neo4j-credentials key: writer-password volumeMounts: - name: src mountPath: /src volumes: - name: src # a checkout sidecar, PVC, or initContainer git clone emptyDir: {}Change the image and args for each language. Java uses canjv -i /src/payments-service -a 2 --emit neo4j --app-name payments-service. TypeScript uses cants -i /src/web-frontend --emit neo4j --app-name web-frontend. Point every job at the same NEO4J_URI. The J*, Py*, and TS* namespacing keeps them apart.
The agent Deployment
Section titled “The agent Deployment”The agents read with a read-only credential. You build the object the same way as in the poll examples. Only two details are specific to Kubernetes: the URI is the in-cluster Neo4j Service, and the password comes from a Secret.
import osfrom cldk import CLDKfrom cldk.analysis.commons.backend_config import Neo4jConnectionConfig
def analysis_for(app_name: str): return CLDK.python( backend=Neo4jConnectionConfig( uri=os.environ["NEO4J_URI"], username=os.environ["NEO4J_USERNAME"], # reader password=os.environ["NEO4J_PASSWORD"], application_name=app_name, ), )CLDK parses no source at query time, so an agent pod starts at once and answers from the graph. Scale the replicas of the Deployment to match the query load. The Neo4j connection pool and your read-replica topology absorb the load.
Walkthrough: Odoo, end to end
Section titled “Walkthrough: Odoo, end to end”Odoo is a good stress test for multi-project analysis at scale. It is large, and it is split into hundreds of addon modules, all Python. A real fleet rarely stops at one language. This walkthrough therefore pairs the Python addons of Odoo with a TypeScript service in the same graph, and queries both through one API.
1. Emit both languages into one graph
Section titled “1. Emit both languages into one graph”Run one Python job over the Odoo addons and one TypeScript job over the storefront service. They use different --app-name values, so both land in the same database as separate application anchors. The namespaced labels keep the Python and TypeScript graphs apart, and an agent can query either one.
canpy -i ./odoo/addons --emit neo4j \ --app-name odoo \ --neo4j-uri bolt://neo4j:7687 \ --neo4j-user writer --neo4j-password "$NEO4J_PASSWORD"# -> writes :PyApplication {name: "odoo"} and its :PyModule / :PySymbol graph# -> ~hundreds of modules; subsequent runs only rewrite changed filescants -i ./storefront/src -a 2 --emit neo4j \ --app-name storefront \ --neo4j-uri bolt://neo4j:7687 \ --neo4j-user writer --neo4j-password "$NEO4J_PASSWORD"# -> writes :TSApplication {name: "storefront"} and its :TSModule / :CanNode graphIn a cluster these are two CronJobs on the same schedule. After the first full run, each later run is an incremental Bolt push. It rewrites only the modules whose content hash changed. The Python and TypeScript writers scope their prunes to the application anchor, so the two apps never touch each other. Keep two Java apps in separate databases (see the prune note).
2. Query across modules and languages
Section titled “2. Query across modules and languages”An agent answers structural questions without a read of either source tree. Build one analysis for each app, anchored to its own application_name:
from cldk import CLDKfrom cldk.analysis.commons.backend_config import Neo4jConnectionConfig
def connect(factory, app): return factory( backend=Neo4jConnectionConfig( uri="bolt://neo4j:7687", username="reader", password="…", application_name=app, ), )
py = connect(CLDK.python, "odoo") # the Odoo addons graphts = connect(CLDK.typescript, "storefront") # the TypeScript service graph
# "Which callables across all addons reach SaleOrder.action_confirm?"# Signatures are rooted at the emit -i directory (./odoo/addons), so no "odoo.addons." prefix.callers = py.get_callers("sale.models.sale_order.SaleOrder", "action_confirm")# -> callers spread across the sale, stock, and account addons — one query, no per-addon parsing
# The same agent inspects the TypeScript service's call graph through the same API.storefront_cg = ts.get_call_graph() # -> networkx.DiGraphThe first query touched modules from several addons in one call. The agent then reached a second language through the same analysis vocabulary. This is the multi-lingual, multi-project result. A new analysis of these projects for every such question is not practical. A read of a precomputed graph is constant work.