Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/configs/graph.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ production:

# Copy operation limits
copy_operations:
max_file_size_gb: 5.0 # Larger files for professional workloads
max_file_size_gb: 5.0 # Larger files for larger workloads
timeout_seconds: 1800 # 30 minute timeout for large operations
concurrent_operations: 3 # Support concurrent operations for team
max_files_per_operation: 1000 # Higher batch limits
daily_copy_operations: 50 # More operations for active development

# Backup limits
backup_limits:
max_backup_size_gb: 50 # Larger backup capacity for professional workloads
max_backup_size_gb: 50 # Larger backup capacity for larger workloads
backup_retention_days: 30 # Extended retention for team collaboration
max_backups_per_day: 10 # More frequent backups for active development

Expand Down
11 changes: 9 additions & 2 deletions robosystems/config/deprovisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ class DeprovisioningConfig:
retention_days: int = 7
require_final_backup: bool = True
backup_delay_hours: int = 24
# 90 days for every tier: the S3 lifecycle rule (cloudformation/s3.yaml,
# ExpireGraphBackups: 90) deletes the object then regardless of what is
# promised here, and the final backup creates no GraphBackup row, so the
# tier-aware cleanup job cannot extend it either. This table once promised
# 180/365 days to Large/XLarge — hosting the infrastructure could not
# deliver. Extending it for real means exempting final backups from the
# lifecycle rule and tracking them in GraphBackup.
backup_hosting_days: dict[str, int] = field(
default_factory=lambda: {
"ladybug-standard": 90,
"ladybug-large": 180,
"ladybug-xlarge": 365,
"ladybug-large": 90,
"ladybug-xlarge": 90,
}
)

Expand Down
13 changes: 6 additions & 7 deletions robosystems/config/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -1260,11 +1260,13 @@ def get_lbug_tier_config(cls) -> dict[str, Any]:
and get_int_env("LBUG_DATABASES_PER_INSTANCE", 0) > 0
else instance_config.get("databases_per_instance", 10)
),
# Tier-level settings from full config
# Tier-level settings from full config. Storage, credits, and
# rate multipliers deliberately do not appear here: those keys
# never existed in graph.yml, so this dict only fabricated
# defaults (500 GB / 10000 / 1.0) that nothing should read.
# Their sources of truth are GraphTierConfig, BillingConfig,
# and RateLimitConfig respectively.
"tier": tier,
"storage_limit_gb": full_tier_config.get("storage_limit_gb", 500),
"monthly_credits": full_tier_config.get("monthly_credits", 10000),
"api_rate_multiplier": full_tier_config.get("api_rate_multiplier", 1.0),
"max_subgraphs": full_tier_config.get("max_subgraphs", 0),
}
except ImportError:
Expand All @@ -1288,9 +1290,6 @@ def get_lbug_tier_config(cls) -> dict[str, Any]:
"max_databases": get_int_env("LBUG_DATABASES_PER_INSTANCE", 10),
# Default tier settings
"tier": "ladybug-standard",
"storage_limit_gb": 500,
"monthly_credits": 10000,
"api_rate_multiplier": 1.0,
"max_subgraphs": 0,
}

Expand Down
48 changes: 21 additions & 27 deletions robosystems/config/graph_tier.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,8 @@ def get_duckdb_max_threads(cls, tier: str, environment: str | None = None) -> in
"""Get DuckDB max threads for a tier.

Thread counts are aligned with instance vCPU counts to prevent oversubscription:
- r7g.medium (1 vCPU): 2 threads (DuckDB benefits from slight oversubscription)
- r7g.large (2 vCPU): 2 threads
- m7g.medium (1 vCPU): 2 threads (DuckDB benefits from slight oversubscription)
- m7g.large (2 vCPU): 2 threads
- r7g.xlarge (4 vCPU): 4 threads

Args:
Expand Down Expand Up @@ -435,28 +435,12 @@ def get_instance_storage_limit_gb(
environment: Environment (defaults to current env)

Returns:
Storage limit in GB (soft cap — used for reporting, not enforcement)
Storage limit in GB. This is the enforced cap: materialization and
file upload reject over it (IngestionLimitChecker, ingest_file).
"""
graph_limits = cls.get_graph_limits(tier, environment)
return float(graph_limits.get("instance_storage_limit_gb", 20))

@classmethod
def get_storage_cap_gb(cls, tier: str, environment: str | None = None) -> float:
"""Get storage safety cap in GB (from backup limits, not billed).

Storage is included in each tier. This cap is a safety valve derived
from the backup size limit in graph.yml.

Args:
tier: The tier name
environment: Environment (defaults to current env)

Returns:
Storage cap in GB
"""
backup_limits = cls.get_backup_limits(tier, environment)
return backup_limits.get("max_backup_size_gb", 10)

@classmethod
def _generate_tier_features(cls, tier_config: dict[str, Any]) -> list[str]:
"""Generate human-readable features list for a tier.
Expand All @@ -475,10 +459,16 @@ def _generate_tier_features(cls, tier_config: dict[str, Any]) -> list[str]:
if storage_limit is not None and storage_limit > 0:
features.append(f"{int(storage_limit)} GB instance storage")

# Add AI credits allocation
monthly_credits = tier_config.get("monthly_credits")
if monthly_credits is not None and monthly_credits > 0:
features.append(f"{monthly_credits:,} AI credits per month")
# Add AI credits allocation. Credits live in billing config, not
# graph.yml — a tier_config.get("monthly_credits") here read a key that
# never exists, so no tier ever advertised its credits.
feature_tier = tier_config.get("tier") or tier_config.get("name")
if feature_tier:
from .billing import BillingConfig

monthly_credits = BillingConfig.get_monthly_credits(feature_tier)
if monthly_credits > 0:
features.append(f"{monthly_credits:,} AI credits per month")

# Add subgraph support
max_subgraphs = tier_config.get("max_subgraphs")
Expand Down Expand Up @@ -515,9 +505,13 @@ def _generate_tier_features(cls, tier_config: dict[str, Any]) -> list[str]:
elif "MEDIUM" in instance_type:
features.append("Dedicated medium instance")

max_memory_mb = instance.get("max_memory_mb", 0)
if max_memory_mb and max_memory_mb > 0:
features.append(f"{max_memory_mb / 1024:.0f}GB RAM")
# Advertise the instance's physical RAM, matching /v1/offering's
# infrastructure line. max_memory_mb is the LadybugDB budget after OS
# overhead — reporting it here made the same tier claim "3GB RAM" in
# one response and "4 GB RAM" in another.
instance_ram_gb = instance.get("instance_ram_gb", 0)
if instance_ram_gb and instance_ram_gb > 0:
features.append(f"{instance_ram_gb:g} GB RAM")

# Add rate limit multiplier if not standard. Derived from the enforced
# limits — graph.yml no longer carries an api_rate_multiplier key.
Expand Down
2 changes: 1 addition & 1 deletion robosystems/dagster/jobs/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ def create_backup(
context.log.info(f"Creating backup for graph {config.graph_id}")

# Validate graph_id
if not MultiTenantUtils.is_shared_repository(config.graph_id):
if not MultiTenantUtils.is_shared_repository_or_subgraph(config.graph_id):
MultiTenantUtils.validate_graph_id(config.graph_id)

database_name = MultiTenantUtils.get_database_name(config.graph_id)
Expand Down
4 changes: 2 additions & 2 deletions robosystems/graph_api/core/duckdb/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,8 @@ def _get_duckdb_max_threads(self) -> int:
3. Default: 4

Thread counts are aligned with instance vCPU counts:
- r7g.medium (1 vCPU): 2 threads
- r7g.large (2 vCPU): 2 threads
- m7g.medium (1 vCPU): 2 threads
- m7g.large (2 vCPU): 2 threads
- r7g.xlarge (4 vCPU): 4 threads

Returns:
Expand Down
2 changes: 1 addition & 1 deletion robosystems/graph_api/core/ladybug/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def create_database(self, request: DatabaseCreateRequest) -> DatabaseCreateRespo
status_code=status.HTTP_400_BAD_REQUEST, detail="Graph ID is required"
)

# Check capacity (bypass for subgraphs on Enterprise/Premium instances)
# Check capacity (bypass for subgraphs — they share the parent's slot)
# Only count primary databases, not subgraphs (which contain '_' in their name)
all_databases = self.list_databases()
current_count = len([db for db in all_databases if "_" not in db])
Expand Down
4 changes: 2 additions & 2 deletions robosystems/middleware/rate_limits/download_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
with monthly TTL expiration.

Limits by product:
- Shared repositories: Defined in adapter manifests (e.g., SEC starter=0, pro=1)
- Dedicated graphs: Defined in billing/core.py (standard=2, large=4, xlarge=10)
- Shared repositories: Defined in adapter manifests (SEC starter=1, advanced=4)
- Dedicated graphs: Defined in billing/core.py (standard=10, large=20, xlarge=40)
"""

from datetime import UTC, datetime
Expand Down
11 changes: 10 additions & 1 deletion robosystems/models/api/graphs/backups.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ class BackupCreateRequest(BaseModel):
description="Backup type - only 'full' is supported",
pattern="^full$", # Only allow full backups
)
retention_days: int = Field(30, ge=1, le=2555, description="Retention period in days")
retention_days: int = Field(
30,
ge=1,
le=90,
description=(
"Retention period in days, further capped to the graph tier's maximum "
"(7/30/90). 90 is the hard ceiling: the storage lifecycle expires "
"backup objects then regardless of the requested value."
),
)
compression: bool = Field(
True, description="Enable compression (always enabled for optimal storage)"
)
Expand Down
46 changes: 23 additions & 23 deletions robosystems/models/api/graphs/limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ class GraphLimitsResponse(BaseModel):
"is_shared_repository": False,
"storage": {
"current_usage_gb": 2.45,
"max_storage_gb": 10,
"max_storage_gb": 20,
"approaching_limit": False,
},
"queries": {
"max_timeout_seconds": 30,
"chunk_size": 1000,
"max_timeout_seconds": 45,
"chunk_size": 500,
"max_rows_per_query": 10000,
"concurrent_queries": 1,
},
Expand All @@ -158,17 +158,17 @@ class GraphLimitsResponse(BaseModel):
},
"rate_limits": {
"requests_per_minute": 60,
"requests_per_hour": 1000,
"burst_capacity": 10,
"requests_per_hour": 3600,
"burst_capacity": 60,
},
"credits": {
"monthly_ai_credits": 8000,
"current_balance": 7500,
},
"content": {
"max_rows_per_copy": 2000000,
"max_single_table_rows": 5000000,
"chunk_size_rows": 1000000,
"max_rows_per_copy": 1000000,
"max_single_table_rows": 2500000,
"chunk_size_rows": 250000,
},
"instance": {
"node_count": 150000,
Expand All @@ -188,33 +188,33 @@ class GraphLimitsResponse(BaseModel):
"graph_tier": "ladybug-shared",
"is_shared_repository": True,
"storage": {
"current_usage_gb": 125.3,
"max_storage_gb": 100,
"current_usage_gb": None,
"max_storage_gb": 20.0,
"approaching_limit": False,
},
"queries": {
"max_timeout_seconds": 120,
"chunk_size": 2000,
"max_timeout_seconds": 300,
"chunk_size": 2500,
"max_rows_per_query": 10000,
"concurrent_queries": 1,
},
"copy_operations": {
"max_file_size_gb": 5.0,
"timeout_seconds": 600,
"concurrent_operations": 2,
"max_files_per_operation": 200,
"daily_copy_operations": 50,
"max_file_size_gb": 10.0,
"timeout_seconds": 3600,
"concurrent_operations": 3,
"max_files_per_operation": 10000,
"daily_copy_operations": -1,
"supported_formats": ["parquet", "csv", "json", "delta", "iceberg"],
},
"backups": {
"max_backup_size_gb": 50,
"backup_retention_days": 30,
"max_backups_per_day": 4,
"max_backup_size_gb": 100,
"backup_retention_days": 90,
"max_backups_per_day": 5,
},
"rate_limits": {
"requests_per_minute": 120,
"requests_per_hour": 2000,
"burst_capacity": 20,
"requests_per_minute": 60,
"requests_per_hour": 3600,
"burst_capacity": 60,
},
},
]
Expand Down
2 changes: 1 addition & 1 deletion robosystems/models/api/graphs/subgraphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ class ListSubgraphsResponse(BaseModel):

subgraphs_enabled: bool = Field(
...,
description="Whether subgraphs are enabled for this tier (requires Large/XLarge tier)",
description="Whether subgraphs are enabled for this tier",
)

subgraph_count: int = Field(..., description="Total number of subgraphs", ge=0)
Expand Down
8 changes: 7 additions & 1 deletion robosystems/models/api/graphs/tier.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ class GraphTierInstance(BaseModel):
"""Instance specifications for a tier."""

type: str = Field(..., description="Instance type identifier")
memory_mb: int = Field(..., description="Memory allocated to your graph in megabytes")
memory_mb: int = Field(
...,
description=(
"LadybugDB memory budget for the whole instance in megabytes (below "
"physical RAM after OS overhead; not a per-graph allocation)"
),
)
is_multitenant: bool = Field(
..., description="Whether this tier shares infrastructure with other graphs"
)
Expand Down
2 changes: 1 addition & 1 deletion robosystems/models/core/graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class Graph(Model):
String, nullable=False, default=GraphTier.LADYBUG_STANDARD.value
) # ladybug-standard, ladybug-large, ladybug-xlarge, etc. (infrastructure tier)

# Subgraph support (Enterprise/Premium only)
# Subgraph support (all dedicated tiers; max count varies by tier)
parent_graph_id = Column(
String, nullable=True, index=True
) # Parent graph ID if this is a subgraph
Expand Down
Loading