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
2,485 changes: 1,248 additions & 1,237 deletions proto/gen/rill/runtime/v1/api.pb.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions proto/gen/rill/runtime/v1/api.pb.validate.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions proto/gen/rill/runtime/v1/runtime.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6039,6 +6039,9 @@ definitions:
$ref: '#/definitions/v1Resource'
nextPageToken:
type: string
initializing:
type: boolean
description: True while the instance may still produce more resources, i.e. it has not finished its initial parse and reconcile.
v1ListTablesResponse:
type: object
properties:
Expand Down
2 changes: 2 additions & 0 deletions proto/rill/runtime/v1/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,8 @@ message ListResourcesRequest {
message ListResourcesResponse {
repeated Resource resources = 1;
string next_page_token = 2;
// True while the instance may still produce more resources, i.e. it has not finished its initial parse and reconcile.
bool initializing = 3;
}

message WatchResourcesRequest {
Expand Down
4 changes: 3 additions & 1 deletion runtime/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ func (c *Client) ListResources(ctx context.Context, req *runtimev1.ListResources
pageSize = 100
}

var initializing bool
resources, err := pagination.CollectAll(ctx, func(ctx context.Context, pageSize uint32, token string) ([]*runtimev1.Resource, string, error) {
pageReq.PageSize = pageSize
pageReq.PageToken = token
page, err := c.RuntimeServiceClient.ListResources(ctx, pageReq, opts...)
if err != nil {
return nil, "", err
}
initializing = page.Initializing
return page.Resources, page.NextPageToken, nil
}, pageSize)
if err != nil {
Expand All @@ -104,7 +106,7 @@ func (c *Client) ListResources(ctx context.Context, req *runtimev1.ListResources
return strings.Compare(an.Name, bn.Name)
})

return &runtimev1.ListResourcesResponse{Resources: resources}, nil
return &runtimev1.ListResourcesResponse{Resources: resources, Initializing: initializing}, nil
}

// Close closes the client connection.
Expand Down
51 changes: 51 additions & 0 deletions runtime/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ type Controller struct {
// Status indicators
closed atomic.Bool // Indicates if the controller is running
closedCh chan struct{} // Closed when the controller is closed
// started indicates that Run has enqueued the initial set of resources.
// Until then the controller has nothing queued and would otherwise look idle.
started bool
// initialized indicates that the initial parse and reconcile has completed. See Initializing.
initialized atomic.Bool
// subscribers tracks subscribers to catalog events.
subscribers map[int]SubscribeCallback
nextSubscriberID int
Expand Down Expand Up @@ -179,6 +184,7 @@ func (c *Controller) Run(ctx context.Context) error {
c.enqueue(r.Meta.Name)
}
}
c.started = true
c.mu.Unlock()

// Ticker for periodically flushing catalog changes
Expand Down Expand Up @@ -418,6 +424,51 @@ func (c *Controller) WaitUntilIdle(ctx context.Context, ignoreHidden bool) error
return ctx.Err()
}

// Initializing returns true until the controller has completed its initial parse and reconcile,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this comment block should maybe add some info on how started and initialized are used instead of talking about callers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to describe how started and initialized are used.

// i.e. until the project parser has parsed the project and the resources it created have been reconciled once.
//
// It combines the two status indicators on the controller:
// started marks the point where Run has enqueued the initial resources, before which the controller has nothing queued and would otherwise look idle;
// initialized latches the first time the checks below all pass.
// The latch means later reconciles, such as a model refresh, do not make the controller look like it is initializing again.
func (c *Controller) Initializing() bool {
if c.initialized.Load() {
return false
}

c.mu.RLock()
defer c.mu.RUnlock()

// Run hasn't enqueued the initial resources yet, so the controller only looks idle.
if !c.started {
return true
}

// There's still work queued or in flight.
// Hidden resources are ignored: they never surface in the UI, and short-lived refresh triggers keep appearing long after startup.
if len(c.queue) != 0 {
return true
}
for _, inv := range c.invocations {
if !inv.isHidden {
return true
}
}

// The project parser is hidden, but it creates every other resource, so we can't be done before it has parsed the project.
// While watching for file changes it stays running indefinitely; it only starts watching after the initial parse.
parser, err := c.catalog.get(GlobalProjectParserName, false, false)
if err != nil {
return true // The parser hasn't been created yet.
}
if parser.Meta.ReconcileStatus != runtimev1.ReconcileStatus_RECONCILE_STATUS_IDLE && !parser.GetProjectParser().GetState().GetWatching() {
return true
}

c.initialized.Store(true)
return false
}

// Get returns a resource by name.
// Soft-deleted resources (i.e. resources where DeletedOn != nil) are not returned.
func (c *Controller) Get(ctx context.Context, name *runtimev1.ResourceName, clone bool) (*runtimev1.Resource, error) {
Expand Down
28 changes: 28 additions & 0 deletions runtime/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1504,3 +1504,31 @@ func localFileHash(t *testing.T, rt *runtime.Runtime, id string, paths []string)
require.NoError(t, err)
return localFileHash
}

func TestControllerInitializing(t *testing.T) {
ctx := context.Background()
rt, id := testruntime.NewInstance(t)
ctrl, err := rt.Controller(ctx, id)
require.NoError(t, err)

testruntime.PutFiles(t, rt, id, map[string]string{
"/models/bar.sql": `SELECT 1 AS a`,
})
testruntime.ReconcileParserAndWait(t, rt, id)
testruntime.RequireReconcileState(t, rt, id, 2, 0, 0)
require.False(t, ctrl.Initializing())

// A refresh is not an initial build, so the instance must not report initializing again,
// not even while the refresh trigger is queued and its model is reconciling.
err = ctrl.Create(ctx, &runtimev1.ResourceName{Kind: runtime.ResourceKindRefreshTrigger, Name: "trigger"}, nil, nil, nil, nil, false, &runtimev1.Resource{
Resource: &runtimev1.Resource_RefreshTrigger{
RefreshTrigger: &runtimev1.RefreshTrigger{
Spec: &runtimev1.RefreshTriggerSpec{
Resources: []*runtimev1.ResourceName{{Kind: runtime.ResourceKindModel, Name: "bar"}},
},
},
},
})
require.NoError(t, err)
require.False(t, ctrl.Initializing())
}
5 changes: 4 additions & 1 deletion runtime/server/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func (s *Server) ListResources(ctx context.Context, req *runtimev1.ListResources
return nil, err
}

initializing := ctrl.Initializing()

if req.SkipSecurityChecks {
if !claims.Can(runtime.ReadInstance) {
return nil, ErrForbidden
Expand Down Expand Up @@ -83,7 +85,7 @@ func (s *Server) ListResources(ctx context.Context, req *runtimev1.ListResources
})

if req.PageSize == 0 {
return &runtimev1.ListResourcesResponse{Resources: rs}, nil
return &runtimev1.ListResourcesResponse{Resources: rs, Initializing: initializing}, nil
}

var afterKind, afterName string
Expand All @@ -108,6 +110,7 @@ func (s *Server) ListResources(ctx context.Context, req *runtimev1.ListResources
return &runtimev1.ListResourcesResponse{
Resources: rs[start:end],
NextPageToken: nextPageToken,
Initializing: initializing,
}, nil
}

Expand Down
61 changes: 61 additions & 0 deletions runtime/server/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/rilldata/rill/runtime/pkg/activity"
"github.com/rilldata/rill/runtime/pkg/ratelimit"
"github.com/rilldata/rill/runtime/server"
"github.com/rilldata/rill/runtime/server/auth"
"github.com/rilldata/rill/runtime/testruntime"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
Expand Down Expand Up @@ -301,3 +302,63 @@ func createTableAsSelect(t *testing.T, rt *runtime.Runtime, instanceID, connecto
})
require.NoError(t, err)
}

func TestListResourcesWithDenyAllSecurity(t *testing.T) {
rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{
Files: map[string]string{
"rill.yaml": "",
"m1.sql": `SELECT 'US' AS country`,
"mv1.yaml": `
type: metrics_view
version: 1
model: m1
dimensions:
- column: country
measures:
- name: count
expression: COUNT(*)

security:
access: "'{{ .user.domain }}' = 'rilldata.com'"
`,
"e1.yaml": `
type: explore
metrics_view: mv1
`,
},
})
testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0)

server, err := server.NewServer(context.Background(), &server.Options{}, rt, zap.NewNop(), ratelimit.NewNoop(), activity.NewNoopClient())
require.NoError(t, err)

// A user who is allowed to see the resources.
ctx := auth.WithClaims(context.Background(), &runtime.SecurityClaims{
UserAttributes: map[string]any{"admin": false, "domain": "rilldata.com"},
Permissions: []runtime.Permission{runtime.ReadObjects},
})
res, err := server.ListResources(ctx, &runtimev1.ListResourcesRequest{InstanceId: instanceID})
require.NoError(t, err)
require.NotEmpty(t, res.Resources)
require.False(t, res.Initializing)

// A user whose security policies deny every resource.
// The response is empty, but initializing must still report that the instance is done building.
ctx = auth.WithClaims(context.Background(), &runtime.SecurityClaims{
UserAttributes: map[string]any{"admin": false, "domain": "notrilldata.com"},
Permissions: []runtime.Permission{runtime.ReadObjects},
})
res, err = server.ListResources(ctx, &runtimev1.ListResourcesRequest{InstanceId: instanceID})
require.NoError(t, err)
require.Empty(t, res.Resources)
require.False(t, res.Initializing)

// The flag is also set when the request filters by kind, which excludes the project parser.
res, err = server.ListResources(ctx, &runtimev1.ListResourcesRequest{
InstanceId: instanceID,
Kind: runtime.ResourceKindExplore,
})
require.NoError(t, err)
require.Empty(t, res.Resources)
require.False(t, res.Initializing)
}
31 changes: 31 additions & 0 deletions web-admin/src/features/dashboards/listing/selectors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isInitialBuild } from "./selectors";

const dashboard = { explore: {} };
const model = { model: {} };

describe("isInitialBuild", () => {
it("is false once a dashboard exists, even while the runtime is still initializing", () => {
expect(
isInitialBuild({ resources: [dashboard, model], initializing: true }),
).toBe(false);
});

it("is true when no dashboards exist yet and the runtime is still initializing", () => {
expect(isInitialBuild({ resources: [model], initializing: true })).toBe(
true,
);
});

it("is false when security policies denied every resource", () => {
// ListResources returns 200 with an empty list. Reading that as "still building"
// left embed users on a deny-by-default project staring at a permanent spinner.
expect(isInitialBuild({ resources: [], initializing: false })).toBe(false);
});

it("is false when the project genuinely has no dashboards", () => {
expect(isInitialBuild({ resources: [model], initializing: false })).toBe(
false,
);
});
});
41 changes: 21 additions & 20 deletions web-admin/src/features/dashboards/listing/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createAdminServiceGetProject } from "@rilldata/web-admin/client";
import {
createSmartRefetchInterval,
isResourceReconciling,
} from "@rilldata/web-admin/lib/refetch-interval-store";
import { createSmartRefetchInterval } from "@rilldata/web-admin/lib/refetch-interval-store";
import { useValidExplores } from "@rilldata/web-common/features/dashboards/selectors";
import type { V1Resource } from "@rilldata/web-common/runtime-client";
import type {
V1ListResourcesResponse,
V1Resource,
} from "@rilldata/web-common/runtime-client";
import { createRuntimeServiceListResources } from "@rilldata/web-common/runtime-client";
import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
import type { CreateQueryResult } from "@tanstack/svelte-query";
Expand Down Expand Up @@ -78,32 +78,33 @@ function isManagedOrShared({
}

/**
* Returns true when the runtime is still in its initial build phase:
* no dashboards exist yet AND non-parser resources are still reconciling.
* Returns true when the runtime is still in its initial build phase, i.e. no dashboards exist yet
* and the runtime reports that more resources may still appear.
*
* Used to show a "building" state instead of "no dashboards yet" during
* deployment startup. In steady state (e.g., a model refresh on a project
* that already has dashboards), this returns false because the dashboards
* exist — even if other resources are reconciling.
* Used to show a "building" state instead of "no dashboards yet" during deployment startup. In
* steady state (e.g., a model refresh on a project that already has dashboards), this returns false
* because the dashboards exist — even if other resources are reconciling.
*
* An empty response is never enough to infer "still building" on its own: ListResources returns 200
* with an empty list when security policies deny every resource, which a deny-by-default project
* does for users who are entitled to nothing. Only `initializing` distinguishes the two.
*/
export function useIsInitialBuild(client: RuntimeClient) {
return createRuntimeServiceListResources(
client,
{},
{
query: {
select: (data): boolean => {
const resources = data.resources;
if (!resources?.length) return true;
const hasDashboards = resources.some((r) => r.canvas || r.explore);
if (hasDashboards) return false;
return resources.some(
(r) => !r.projectParser && isResourceReconciling(r),
);
},
select: isInitialBuild,
enabled: !!client.instanceId,
refetchInterval: dashboardRefetchInterval,
},
},
);
}

export function isInitialBuild(data: V1ListResourcesResponse): boolean {
const resources = data.resources ?? [];
if (resources.some((r) => r.canvas || r.explore)) return false;
return data.initializing ?? false;
}
14 changes: 13 additions & 1 deletion web-admin/src/features/embeds/ExploreEmbed.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import StateManagersProvider from "@rilldata/web-common/features/dashboards/state-managers/StateManagersProvider.svelte";
import DashboardStateManager from "@rilldata/web-common/features/dashboards/state-managers/loaders/DashboardStateManager.svelte";
import { derived } from "svelte/store";
import { isNotFoundError } from "@rilldata/web-common/lib/errors";
import {
extractErrorStatusCode,
isNotFoundError,
} from "@rilldata/web-common/lib/errors";
import { createRuntimeServiceGetExplore } from "@rilldata/web-common/runtime-client";
import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2";
import { errorStore } from "../../components/errors/error-store";
Expand All @@ -23,6 +26,9 @@
});
$: ({ isSuccess, isError, error, data } = $explore);
$: isExploreNotFound = isError && isNotFoundError(error);
// The runtime denies a resource the user's security policies exclude, which is the normal outcome
// for an embed user on a deny-by-default project. Without this the page renders nothing at all.
$: isExploreForbidden = isError && extractErrorStatusCode(error) === 403;

// We check for explore.state.validSpec instead of meta.reconcileError. validSpec persists
// from previous valid explores, allowing display even when the current explore spec is invalid
Expand All @@ -43,6 +49,12 @@
header: m.embed_explore_not_found(),
body: m.embed_explore_not_found_body(),
});
} else if (isExploreForbidden) {
errorStore.set({
statusCode: 403,
header: m.error_access_denied_header(),
body: m.error_access_denied_body(),
});
}
</script>

Expand Down
Loading
Loading