graphql-engine icon indicating copy to clipboard operation
graphql-engine copied to clipboard

Issue with experimental flag "remove_empty_subscription_responses"

Open ko-pp opened this issue 5 months ago • 6 comments

Version Information

Server Version: 2.48.3

Environment

OSS

What is the current behaviour?

When enabling the experimental flag remove_empty_subscription_responses, and starting a subscription with no results, we get the following database query error: FatalError cannot get array length of a non-array

Subscriptions with results don’t seem affected.

What is the expected behaviour?

We should get a response with an empty array as is the case without the flag.

How to reproduce the issue?

  1. Start Hasura with env variable HASURA_GRAPHQL_EXPERIMENTAL_FEATURES set to remove_empty_subscription_responses
  2. Make a subscription with filters so that there is no selected row
  3. cannot get array length of a non-array

Please provide any traces or logs that could help here.

Log from such an error this morning
{
    "detail": {
        "connection_info": {
            "msg": null,
            "token_expiry": null,
            "websocket_id": "c6d135c6-caa4-4d2d-a01b-c88a40778d02"
        },
        "event": {
            "detail": {
                "operation_id": "c30a0851-8cd8-4ee0-860a-be1faec429e0",
                "operation_name": null,
                "operation_type": {
                    "detail": {
                        "code": "unexpected",
                        "error": "database query error",
                        "internal": {
                            "arguments": [
                                "(Oid 705,Just (\"\\NUL\\NUL\\NUL\\SOH\\NUL\\NUL\\NUL\\NUL\\NUL\\NUL\\v\\134\\NUL\\NUL\\NUL\\SOH\\NUL\\NUL\\NUL\\SOH\\NUL\\NUL\\NUL\\DLE\\206\\ENQM#\\ETB\\167E\\DC4\\135<\\229\\175w`<\\143\",Binary))",
                                "(Oid 3807,Just (\"\\NUL\\NUL\\NUL\\SOH\\NUL\\NUL\\NUL\\NUL\\NUL\\NUL\\SO\\218\\NUL\\NUL\\NUL\\SOH\\NUL\\NUL\\NUL\\SOH\\NUL\\NUL\\NUL\\138\\SOH{\\\"cursor\\\":{},\\\"query\\\":{},\\\"session\\\":{\\\"x-hasura-user-id\\\":\\\"4\\\"},\\\"synthetic\\\":[\\\"0.0\\\",\\\"false\\\",\\\"{-1,5}\\\",\\\"4\\\",\\\"false\\\",\\\"true\\\",\\\"false\\\",\\\"REGULAR\\\",\\\"0\\\"]}\",Binary))"
                            ],
                            "error": {
                                "description": null,
                                "exec_status": "FatalError",
                                "hint": null,
                                "message": "cannot get array length of a non-array",
                                "status_code": "22023"
                            },
                            "prepared": true,
                            "statement": "SELECT  *  FROM  (SELECT  \"__subs\".\"result_id\" , \"__fld_resp\".\"root\" AS \"result\" FROM UNNEST(($1)::uuid[], ($2)::json[]) AS \"__subs\"(\"result_id\", \"result_vars\") LEFT OUTER JOIN LATERAL (SELECT  json_build_object('user_user_aggregate', \"_user_user_aggregate\".\"root\" ) AS \"root\" FROM  (SELECT  json_build_object('aggregate', json_build_object('count', COUNT(*) ) ) AS \"root\" FROM  (SELECT  1  FROM  (SELECT  *  FROM \"public\".\"user_user\"  WHERE ((EXISTS  (SELECT  1  FROM \"public\".\"user_user\" AS \"__exists_table_0\" WHERE ((((((\"__exists_table_0\".\"id\") = (((\"__subs\".\"result_vars\"#>>ARRAY['session', 'x-hasura-user-id']))::integer)) AND ('true')) AND ('true')) AND (((((\"__exists_table_0\".\"is_active\") = (('true')::boolean)) AND ('true')) AND ('true')) AND (((((\"__exists_table_0\".\"first_login\") = (('false')::boolean)) AND ('true')) AND ('true')) AND ('true')))) AND ('true'))     )) AND ((EXISTS  (SELECT  1  FROM \"public\".\"invoice_salary\" AS \"__be_1_invoice_salary\" WHERE ((((\"__be_1_invoice_salary\".\"payed_id\") = (\"public\".\"user_user\".\"id\")) AND ('true')) AND (((EXISTS  (SELECT  1  FROM \"public\".\"user_user\" AS \"__exists_table_2\" WHERE ((((((\"__exists_table_2\".\"id\") = (((\"__subs\".\"result_vars\"#>>ARRAY['session', 'x-hasura-user-id']))::integer)) AND ('true')) AND ('true')) AND (((((\"__exists_table_2\".\"is_active\") = (('true')::boolean)) AND ('true')) AND ('true')) AND (((((\"__exists_table_2\".\"first_login\") = (('false')::boolean)) AND ('true')) AND ('true')) AND ('true')))) AND ('true'))     )) AND ('true')) AND ((((\"__be_1_invoice_salary\".\"to_pay\") > (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '0']))::numeric)) AND ('true')) AND ((EXISTS  (SELECT  1  FROM \"public\".\"invoice_invoice\" AS \"__be_3_invoice_invoice\" WHERE ((((\"__be_3_invoice_invoice\".\"id\") = (\"__be_1_invoice_salary\".\"invoice_id\")) AND ('true')) AND (((EXISTS  (SELECT  1  FROM \"public\".\"user_user\" AS \"__exists_table_4\" WHERE ((((((\"__exists_table_4\".\"id\") = (((\"__subs\".\"result_vars\"#>>ARRAY['session', 'x-hasura-user-id']))::integer)) AND ('true')) AND ('true')) AND (((((\"__exists_table_4\".\"is_active\") = (('true')::boolean)) AND ('true')) AND ('true')) AND (((((\"__exists_table_4\".\"first_login\") = (('false')::boolean)) AND ('true')) AND ('true')) AND ('true')))) AND ('true'))     )) AND ('true')) AND ((((\"__be_3_invoice_invoice\".\"abandonned\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '1']))::boolean)) AND ('true')) AND ('true'))))     )) AND (((NOT ((\"__be_1_invoice_salary\".\"state\") = ANY(((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '2']))::integer[]))) AND ('true')) AND ('true'))))))     )) AND (((\"public\".\"user_user\".\"referent_id\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '3']))::integer)) AND ((EXISTS  (SELECT  1  FROM \"public\".\"student_student\" AS \"__be_5_student_student\" WHERE ((((\"__be_5_student_student\".\"user_id\") = (\"public\".\"user_user\".\"id\")) AND ('true')) AND (((EXISTS  (SELECT  1  FROM \"public\".\"user_user\" AS \"__exists_table_6\" WHERE ((((((\"__exists_table_6\".\"id\") = (((\"__subs\".\"result_vars\"#>>ARRAY['session', 'x-hasura-user-id']))::integer)) AND ('true')) AND ('true')) AND (((((\"__exists_table_6\".\"is_active\") = (('true')::boolean)) AND ('true')) AND ('true')) AND (((((\"__exists_table_6\".\"first_login\") = (('false')::boolean)) AND ('true')) AND ('true')) AND (((((\"__exists_table_6\".\"is_staff\") = (('true')::boolean)) AND ('true')) AND ('true')) AND ('true'))))) AND ('true'))     )) AND ('true')) AND ((((\"__be_5_student_student\".\"mgp_nationality_blocked\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '4']))::boolean)) AND ('true')) AND ((((((\"__be_5_student_student\".\"need_sca_enroll\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '5']))::boolean)) AND ('true')) AND ('true')) OR (((((\"__be_5_student_student\".\"has_active_recipient\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '6']))::boolean)) AND ('true')) AND ('true')) OR ('false'))) AND ('true')))))     )) OR (EXISTS  (SELECT  1  FROM  (SELECT  count(* ) AS \"mango_pay_naturaluser_count\" FROM \"public\".\"mango_pay_naturaluser\" AS \"__be_7_mango_pay_naturaluser\" WHERE (((\"__be_7_mango_pay_naturaluser\".\"user_id\") = (\"public\".\"user_user\".\"id\")) AND ((EXISTS  (SELECT  1  FROM \"public\".\"user_user\" AS \"__exists_table_8\" WHERE ((((((\"__exists_table_8\".\"id\") = (((\"__subs\".\"result_vars\"#>>ARRAY['session', 'x-hasura-user-id']))::integer)) AND ('true')) AND ('true')) AND (((((\"__exists_table_8\".\"is_active\") = (('true')::boolean)) AND ('true')) AND ('true')) AND (((((\"__exists_table_8\".\"first_login\") = (('false')::boolean)) AND ('true')) AND ('true')) AND ('true')))) AND ('true'))     )) AND ((\"__be_7_mango_pay_naturaluser\".\"kyc_level\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '7']))::\"mgp_naturaluser_kyc_level\"))))     ) AS \"__sub\" WHERE ((\"__sub\".\"mango_pay_naturaluser_count\") = (((\"__subs\".\"result_vars\"#>>ARRAY['synthetic', '8']))::integer))     ))))))     ) AS \"_root.base\"      ) AS \"_root\"      ) AS \"_user_user_aggregate\"      ) AS \"__fld_resp\" ON ('true')      ) AS \"__multiplex\" WHERE (EXISTS  (SELECT  1  FROM \"json_each\"(\"result\")  WHERE ((json_array_length(\"value\" )) > ('0'))     ))     "
                        },
                        "path": "$"
                    },
                    "type": "query_err"
                },
                "parameterized_query_hash": null,
                "query": null,
                "request_id": "3e76a5fb-fef1-4755-b1a8-a35d95c42155"
            },
            "type": "operation"
        },
        "user_vars": {
            "x-hasura-role": "staff",
            "x-hasura-user-id": "4"
        }
    },
    "level": "error",
    "timestamp": "2025-07-29T10:26:17.819+0000",
    "type": "websocket-log"
}

Any possible solutions/workarounds you're aware of?

Disabling the feature makes the subscription work again, but we were hoping that it would reduce our Postgresql server load (see https://github.com/hasura/graphql-engine/issues/9994).

Extra note

I wanted to make a smaller subscription directly from the web console to provide it along a shorter log but GraphiQL doesn’t work because it tries to POST to https://v1/graphql, ignoring the domain name. I am unsure whether this warrant a new issue (the flag has no incidence on it) or is a problem on my end (perhaps a faulty browser cache)

ko-pp avatar Jul 29 '25 13:07 ko-pp

Thanks, @ko-pp — let me share this internally and see what folks have to say. Glad that disabling it works, but totally understand that you're looking to optimize perf here 👍

robertjdominguez avatar Jul 29 '25 14:07 robertjdominguez

@ko-pp thanks for the report, I'll look into this.

To save time triaging, are you getting this with streaming subscriptions (the ones that use a cursor), or regular subscription/live-queries? If you can post an anonymized version of your graphql query that might help as well

jberryman avatar Jul 29 '25 15:07 jberryman

Hello, it’s a regular subscription

Here is the full query used to get the error in the first message
subscription {
    user_user(
        order_by: {id: desc}
        where: {
            salaries: {
                state: {_nin: [-1, 5]}
                to_pay: {_gt: 0}
                invoice: {
                    abandonned: {_eq: false}
                }
            },
            _or: [
                {
                    student: {
                        mgp_nationality_blocked: {_eq: false},
                        _or: [
                            { need_sca_enroll: { _eq: true } },
                            { has_active_recipient: { _eq: false } }
                        ]
                    }
                },
                {
                    natural_users_aggregate: {
                        count: {
                            predicate: { _eq: 0 },
                            filter: {
                                kyc_level: { _eq: "REGULAR" }
                            }
                        }
                    }
                }
            ]
        }
    ) {
        id
        first_name
        last_name
        user_hubspot_id
        salaries (
            where: {
                state: {_nin: [-1, 5]},
                invoice: {
                    abandonned: {_eq: false}
                }
            },
            order_by: {
                invoice: {period_start: asc}
            },
            limit: 1
        ) {
            invoice {
                period_start
            }
        }
        salaries_aggregate(
            where: {
                state: {_nin: [-1, 5]}
                invoice: {
                    abandonned: {_eq: false}
                }
            }
        ) {
            aggregate {
                sum {
                    to_pay
                }
            }
        }
        natural_users(
                order_by: {created_at: desc},
                limit: 1
            ) {
            flows_blocked_code
            inflows_blocked
            natural_id
            outflows_blocked
            kyc_documents(
                order_by: {created_at: desc},
                limit: 1
            ) {
                created_at
                document_id
                refused_reason_message
                refused_reason_type
                status
            }
        }
        student {
            need_sca_enroll
            has_active_recipient
        }
    }
}

I also tried setting up a simpler query but I get neither a response nor errors in the logs for this one:

subscription {
    user_user(where: {id: {_eq: 0}}) {
        id
    }
}

I do get a response with an empty array for both queries without the flag.

ko-pp avatar Jul 29 '25 15:07 ko-pp

but I get neither a response nor errors in the logs for this one ... I do get a response with an empty array for both queries without the flag.

This is expected behavior, although I think we need to document it better. Empty result sets will never be returned. This should work as expected for streaming subscriptions but might not be desirable for regular subscriptions (you won't notice when a subscription goes from returning some result to an empty result, for instance).

Leaving aside the bug you originally reported, does this behavior actually work for your use case? i.e. is it still useful for you, or desired, for the subscriber to never get an updated response when empty?

jberryman avatar Jul 30 '25 16:07 jberryman

is it still useful for you, or desired, for the subscriber to never get an updated response when empty?

The use-case for the big query is to list issues to be resolved on a dashboard for our staff (it’s one of several); we need both

  1. an empty initial response if there is no issue to be fixed as we wouldn’t otherwise easily know if there is nothing to display or if the connection/server is slow
  2. an empty array response when all issues are resolved to empty the displayed list

If not returning at all is expected behavior, we won’t be using this flag and either endure the spikes of DB usage whenever someone is on the dashboard, or rework it (as well as other subscriptions) to use periodical queries instead 🤔

ko-pp avatar Jul 30 '25 19:07 ko-pp

I see. And it doesn't sound like a streaming subscription would work for your dashboard either.

We have a fix for the bug originally reported, but unfortunately won't be able to get rid of those two caveats with regular subscriptions at this time. The trouble is we rely on postgres to construct the json response as the client will receive it, and at the point in the codebase that we receive the database response we don't have things like alias and field type (array or scalar/object) available for reconstructing the "empty" responses ([] or null).

Not a satisfying answer but one workaround might be to store a special fake user which you filter out in your dashboard logic

jberryman avatar Aug 04 '25 15:08 jberryman